diff --git a/catalyst-toolbox/Cargo.toml b/catalyst-toolbox/Cargo.toml index c2da8d67..a2a0599f 100644 --- a/catalyst-toolbox/Cargo.toml +++ b/catalyst-toolbox/Cargo.toml @@ -56,7 +56,7 @@ image = "0.23.12" qrcode = "0.12" quircs = "0.10.0" symmetric-cipher = { git = "https://github.com/input-output-hk/chain-wallet-libs.git", branch = "master" } -graphql_client = "0.10" +graphql_client = { version = "0.10" } gag = "1" vit-servicing-station-lib = { git = "https://github.com/input-output-hk/vit-servicing-station.git", branch = "master" } env_logger = "0.9" diff --git a/catalyst-toolbox/src/bin/cli/rewards/voters.rs b/catalyst-toolbox/src/bin/cli/rewards/voters.rs index 4c2423c0..1894746c 100644 --- a/catalyst-toolbox/src/bin/cli/rewards/voters.rs +++ b/catalyst-toolbox/src/bin/cli/rewards/voters.rs @@ -1,13 +1,9 @@ use catalyst_toolbox::rewards::voters::{calc_voter_rewards, Rewards, VoteCount}; -use catalyst_toolbox::snapshot::{ - registration::MainnetRewardAddress, voting_group::VotingGroupAssigner, Snapshot, -}; +use catalyst_toolbox::snapshot::{registration::MainnetRewardAddress, SnapshotInfo}; use catalyst_toolbox::utils::assert_are_close; use color_eyre::Report; -use fraction::Fraction; use jcli_lib::jcli_lib::block::Common; -use jormungandr_lib::{crypto::account::Identifier, interfaces::Block0Configuration}; use structopt::StructOpt; use std::collections::BTreeMap; @@ -22,19 +18,9 @@ pub struct VotersRewards { #[structopt(long)] total_rewards: u64, - /// Path to raw snapshot + /// Path to a json encoded list of `SnapshotInfo` #[structopt(long)] - snapshot_path: PathBuf, - - /// Stake threshold to be able to participate in a Catalyst sidechain - /// Registrations with less than the threshold associated to the stake address - /// will be ignored - #[structopt(long)] - registration_threshold: u64, - - /// Voting power cap for each account - #[structopt(short, long)] - voting_power_cap: Fraction, + snapshot_info_path: PathBuf, #[structopt(long)] votes_count_path: PathBuf, @@ -66,30 +52,22 @@ impl VotersRewards { let VotersRewards { common, total_rewards, - snapshot_path, - registration_threshold, + snapshot_info_path, votes_count_path, vote_threshold, - voting_power_cap, } = self; - let block = common.input.load_block()?; - let block0 = Block0Configuration::from_block(&block)?; let vote_count: VoteCount = serde_json::from_reader(jcli_lib::utils::io::open_file_read( &Some(votes_count_path), )?)?; - let snapshot = Snapshot::from_raw_snapshot( - serde_json::from_reader(jcli_lib::utils::io::open_file_read(&Some(snapshot_path))?)?, - registration_threshold.into(), - voting_power_cap, - &DummyAssigner, + let snapshot: Vec = serde_json::from_reader( + jcli_lib::utils::io::open_file_read(&Some(snapshot_info_path))?, )?; let results = calc_voter_rewards( vote_count, vote_threshold, - &block0, snapshot, Rewards::from(total_rewards), )?; @@ -101,10 +79,3 @@ impl VotersRewards { Ok(()) } } - -struct DummyAssigner; -impl VotingGroupAssigner for DummyAssigner { - fn assign(&self, _vk: &Identifier) -> String { - unimplemented!() - } -} diff --git a/catalyst-toolbox/src/bin/cli/snapshot/mod.rs b/catalyst-toolbox/src/bin/cli/snapshot/mod.rs index 5523561f..dad41200 100644 --- a/catalyst-toolbox/src/bin/cli/snapshot/mod.rs +++ b/catalyst-toolbox/src/bin/cli/snapshot/mod.rs @@ -20,7 +20,7 @@ pub struct SnapshotCmd { snapshot: PathBuf, /// Registrations voting power threshold for eligibility #[structopt(short, long)] - threshold: Value, + min_stake_threshold: Value, /// Voter group to assign direct voters to. /// If empty, defaults to "voter" @@ -59,7 +59,7 @@ impl SnapshotCmd { let assigner = RepsVotersAssigner::new(direct_voter, representative, self.reps_db_api_url)?; let initials = Snapshot::from_raw_snapshot( raw_snapshot, - self.threshold, + self.min_stake_threshold, self.voting_power_cap, &assigner, )? diff --git a/catalyst-toolbox/src/rewards/voters.rs b/catalyst-toolbox/src/rewards/voters.rs index d5daae7f..a9e24e6d 100644 --- a/catalyst-toolbox/src/rewards/voters.rs +++ b/catalyst-toolbox/src/rewards/voters.rs @@ -1,14 +1,9 @@ -use crate::snapshot::{registration::MainnetRewardAddress, Snapshot}; -use chain_addr::{Discrimination, Kind}; -use chain_impl_mockchain::transaction::UnspecifiedAccountIdentifier; -use chain_impl_mockchain::vote::CommitteeId; +use crate::snapshot::{registration::MainnetRewardAddress, SnapshotInfo}; use jormungandr_lib::crypto::account::Identifier; use rust_decimal::Decimal; use std::collections::{BTreeMap, HashMap, HashSet}; use thiserror::Error; -use jormungandr_lib::interfaces::{Address, Block0Configuration, Initial}; - pub const ADA_TO_LOVELACE_FACTOR: u64 = 1_000_000; pub type Rewards = Decimal; @@ -16,121 +11,58 @@ pub type Rewards = Decimal; pub enum Error { #[error("Value overflowed its maximum value")] Overflow, + #[error("Multiple snapshot entries per voter are not supported")] + MultipleEntries, + #[error("Unknown voter group {0}")] + UnknownVoterGroup(String), } -fn calculate_active_stake<'address>( - committee_keys: &HashSet
, - block0: &'address Block0Configuration, - active_addresses: &ActiveAddresses, -) -> Result<(u64, HashMap<&'address Address, u64>), Error> { - let mut total_stake: u64 = 0; - let mut stake_per_voter: HashMap<&'address Address, u64> = HashMap::new(); - - for fund in &block0.initial { - match fund { - Initial::Fund(_) => {} - Initial::Cert(_) => {} - Initial::LegacyFund(_) => {} - Initial::Token(token) => { - for destination in &token.to { - // Exclude committee addresses (managed by IOG) and - // non active voters from total active stake for the purpose of calculating - // voter rewards - if !committee_keys.contains(&destination.address) - && active_addresses.contains(&destination.address) - { - let value: u64 = destination.value.into(); - total_stake = total_stake.checked_add(value).ok_or(Error::Overflow)?; - let entry = stake_per_voter.entry(&destination.address).or_default(); - *entry += value; - } - } - } - } - } - Ok((total_stake, stake_per_voter)) -} - -fn calculate_reward<'address>( +fn calculate_reward( total_stake: u64, - stake_per_voter: &HashMap<&'address Address, u64>, - active_addresses: &ActiveAddresses, + stake_per_voter: HashMap, total_rewards: Rewards, -) -> HashMap<&'address Address, Rewards> { +) -> HashMap { stake_per_voter - .iter() + .into_iter() .map(|(k, v)| { - let reward = if active_addresses.contains(k) { - Rewards::from(*v) / Rewards::from(total_stake) * total_rewards - } else { - Rewards::ZERO - }; - (*k, reward) + ( + k, + (Rewards::from(v) / Rewards::from(total_stake) * total_rewards), + ) }) .collect() } pub type VoteCount = HashMap; -pub type ActiveAddresses = HashSet
; -fn active_addresses( +fn filter_active_addresses( vote_count: VoteCount, - block0: &Block0Configuration, threshold: u64, - snapshot: &Snapshot, -) -> ActiveAddresses { - let discrimination = block0.blockchain_configuration.discrimination; - snapshot - .voting_keys() - // Add all keys from snapshot so that they are accounted for - // even if they didn't vote and the threshold is 0. - // Active accounts are overwritten with the correct count. - .map(|key| (key.to_hex(), 0)) - .chain(vote_count.into_iter()) - .filter_map(|(account_hex, count)| { - if count >= threshold { - Some( - account_hex_to_address(account_hex, discrimination) - .expect("Valid hex encoded UnspecifiedAccountIdentifier"), - ) - } else { - None - } + snapshot_info: Vec, +) -> Vec { + snapshot_info + .into_iter() + .filter(|v| { + let addr = v.hir.voting_key.to_hex(); + vote_count.get(&addr).copied().unwrap_or_default() >= threshold }) .collect() } -fn account_hex_to_address( - account_hex: String, - discrimination: Discrimination, -) -> Result { - let mut buffer = [0u8; 32]; - hex::decode_to_slice(account_hex, &mut buffer)?; - let identifier: UnspecifiedAccountIdentifier = UnspecifiedAccountIdentifier::from(buffer); - Ok(Address::from(chain_addr::Address( - discrimination, - Kind::Account( - identifier - .to_single_account() - .expect("Only single accounts are supported") - .into(), - ), - ))) -} - fn rewards_to_mainnet_addresses( - rewards: HashMap<&'_ Address, Rewards>, - snapshot: Snapshot, + rewards: HashMap, + voters: Vec, ) -> BTreeMap { let mut res = BTreeMap::new(); + let snapshot_info_by_key = voters + .into_iter() + .map(|v| (v.hir.voting_key.clone(), v)) + .collect::>(); for (addr, reward) in rewards { - let contributions = snapshot.contributions_for_voting_key::( - addr.1 - .public_key() - .expect("non account address") - .clone() - .into(), - ); + let contributions = snapshot_info_by_key + .get(&addr) + .map(|v| v.contributions.clone()) + .unwrap_or_default(); let total_value = contributions .iter() .map(|c| Rewards::from(c.value)) @@ -148,33 +80,29 @@ fn rewards_to_mainnet_addresses( pub fn calc_voter_rewards( vote_count: VoteCount, vote_threshold: u64, - block0: &Block0Configuration, - snapshot: Snapshot, + voters: Vec, total_rewards: Rewards, ) -> Result, Error> { - let active_addresses = active_addresses(vote_count, block0, vote_threshold, &snapshot); - - let committee_keys: HashSet
= block0 - .blockchain_configuration - .committees + let unique_voters = voters .iter() - .cloned() - .map(|id| { - let id = CommitteeId::from(id); - let pk = id.public_key(); + .map(|s| s.hir.voting_key.clone()) + .collect::>(); + if unique_voters.len() != voters.len() { + return Err(Error::MultipleEntries); + } + let active_addresses = filter_active_addresses(vote_count, vote_threshold, voters); - chain_addr::Address(Discrimination::Production, Kind::Account(pk)).into() - }) - .collect(); - let (total_active_stake, stake_per_voter) = - calculate_active_stake(&committee_keys, block0, &active_addresses)?; - let rewards = calculate_reward( - total_active_stake, - &stake_per_voter, - &active_addresses, - total_rewards, - ); - Ok(rewards_to_mainnet_addresses(rewards, snapshot)) + let mut total_active_stake = 0u64; + let mut stake_per_voter = HashMap::new(); + // iterative as Iterator::sum() panic on overflows + for voter in &active_addresses { + total_active_stake = total_active_stake + .checked_add(voter.hir.voting_power.into()) + .ok_or(Error::Overflow)?; + stake_per_voter.insert(voter.hir.voting_key.clone(), voter.hir.voting_power.into()); + } + let rewards = calculate_reward(total_active_stake, stake_per_voter, total_rewards); + Ok(rewards_to_mainnet_addresses(rewards, active_addresses)) } #[cfg(test)] @@ -183,42 +111,10 @@ mod tests { use crate::snapshot::registration::*; use crate::snapshot::*; use crate::utils::assert_are_close; - use chain_impl_mockchain::chaintypes::ConsensusVersion; - use chain_impl_mockchain::fee::LinearFee; - use chain_impl_mockchain::tokens::{identifier::TokenIdentifier, name::TokenName}; use fraction::Fraction; use jormungandr_lib::crypto::account::Identifier; - use jormungandr_lib::interfaces::BlockchainConfiguration; - use jormungandr_lib::interfaces::{Destination, Initial, InitialToken, InitialUTxO}; - use std::convert::TryFrom; use test_strategy::proptest; - fn blockchain_configuration(initial_funds: Vec) -> Block0Configuration { - Block0Configuration { - blockchain_configuration: BlockchainConfiguration::new( - Discrimination::Test, - ConsensusVersion::Bft, - LinearFee::new(1, 1, 1), - ), - // Temporarily create dummy until we update the snapshot - initial: vec![Initial::Token(InitialToken { - token_id: TokenIdentifier { - policy_hash: [0; 28].into(), - token_name: TokenName::try_from(Vec::new()).unwrap(), - } - .into(), - policy: Default::default(), - to: initial_funds - .into_iter() - .map(|utxo| Destination { - address: utxo.address, - value: utxo.value, - }) - .collect(), - })], - } - } - #[proptest] fn test_all_active(snapshot: Snapshot) { let votes_count = snapshot @@ -227,9 +123,8 @@ mod tests { .map(|key| (key.to_hex(), 1)) .collect::(); let n_voters = votes_count.len(); - let initial = snapshot.to_block0_initials(Discrimination::Test); - let block0 = blockchain_configuration(initial); - let rewards = calc_voter_rewards(votes_count, 1, &block0, snapshot, Rewards::ONE).unwrap(); + let voters = snapshot.to_full_snapshot_info(); + let rewards = calc_voter_rewards(votes_count, 1, voters, Rewards::ONE).unwrap(); if n_voters > 0 { assert_are_close(rewards.values().sum::(), Rewards::ONE) } else { @@ -240,9 +135,8 @@ mod tests { #[proptest] fn test_all_inactive(snapshot: Snapshot) { let votes_count = VoteCount::new(); - let initial = snapshot.to_block0_initials(Discrimination::Test); - let block0 = blockchain_configuration(initial); - let rewards = calc_voter_rewards(votes_count, 1, &block0, snapshot, Rewards::ONE).unwrap(); + let voters = snapshot.to_full_snapshot_info(); + let rewards = calc_voter_rewards(votes_count, 1, voters, Rewards::ONE).unwrap(); assert_eq!(rewards.len(), 0); } @@ -256,33 +150,18 @@ mod tests { .map(|(i, key)| (key.to_hex(), (i % 2 == 0) as u64)) .collect::(); let n_voters = votes_count.iter().filter(|(_, votes)| **votes > 0).count(); - let initial = snapshot.to_block0_initials(Discrimination::Test); - let initial_active = initial - .iter() - .cloned() + let voters = snapshot.to_full_snapshot_info(); + let voters_active = voters + .clone() + .into_iter() .enumerate() .filter(|(i, _utxo)| i % 2 == 0) .map(|(_, utxo)| utxo) .collect::>(); - let block0 = blockchain_configuration(initial); - let block0_active = blockchain_configuration(initial_active); - let mut rewards = calc_voter_rewards( - votes_count.clone(), - 1, - &block0, - snapshot.clone(), - Rewards::ONE, - ) - .unwrap(); - let rewards_no_inactive = calc_voter_rewards( - votes_count, - 1, - &block0_active, - snapshot.clone(), - Rewards::ONE, - ) - .unwrap(); + let mut rewards = calc_voter_rewards(votes_count.clone(), 1, voters, Rewards::ONE).unwrap(); + let rewards_no_inactive = + calc_voter_rewards(votes_count, 1, voters_active, Rewards::ONE).unwrap(); // Rewards should ignore inactive voters assert_eq!(rewards, rewards_no_inactive); if n_voters > 0 { @@ -347,11 +226,9 @@ mod tests { ) .unwrap(); - let initial = snapshot.to_block0_initials(Discrimination::Test); - let block0 = blockchain_configuration(initial); + let voters = snapshot.to_full_snapshot_info(); - let rewards = - calc_voter_rewards(VoteCount::new(), 0, &block0, snapshot, Rewards::ONE).unwrap(); + let rewards = calc_voter_rewards(VoteCount::new(), 0, voters, Rewards::ONE).unwrap(); assert_eq!(rewards.values().sum::(), Rewards::ONE); for (addr, reward) in rewards { assert_eq!( @@ -360,4 +237,39 @@ mod tests { ); } } + + #[test] + fn test_rewards_cap() { + let mut raw_snapshot = Vec::new(); + + for i in 1..10u64 { + let voting_pub_key = Identifier::from_hex(&hex::encode([i as u8; 32])).unwrap(); + let stake_public_key = i.to_string(); + let reward_address = i.to_string(); + let delegations = Delegations::New(vec![(voting_pub_key.clone(), 1)]); + raw_snapshot.push(VotingRegistration { + stake_public_key, + voting_power: i.into(), + reward_address, + delegations, + voting_purpose: 0, + }); + } + + let snapshot = Snapshot::from_raw_snapshot( + raw_snapshot.into(), + 0.into(), + Fraction::new(1u64, 9u64), + &|_vk: &Identifier| String::new(), + ) + .unwrap(); + + let voters = snapshot.to_full_snapshot_info(); + + let rewards = calc_voter_rewards(VoteCount::new(), 0, voters, Rewards::ONE).unwrap(); + assert_are_close(rewards.values().sum::(), Rewards::ONE); + for (_, reward) in rewards { + assert_eq!(reward, Rewards::ONE / Rewards::from(9)); + } + } } diff --git a/catalyst-toolbox/src/snapshot/influence_cap.rs b/catalyst-toolbox/src/snapshot/influence_cap.rs index 3782e2fa..c4dc4fcc 100644 --- a/catalyst-toolbox/src/snapshot/influence_cap.rs +++ b/catalyst-toolbox/src/snapshot/influence_cap.rs @@ -1,4 +1,4 @@ -use super::{Error, VoterEntry}; +use super::{Error, SnapshotInfo}; use fraction::{BigFraction, Fraction}; use rust_decimal::prelude::ToPrimitive; @@ -46,9 +46,9 @@ fn calc_vp_to_remove(x: u64, tot: u64, threshold: Fraction) -> u64 { /// /// Complexity: O(NlogN + min(ceil(1/T), N)) pub fn cap_voting_influence( - mut voters: Vec, + mut voters: Vec, threshold: Fraction, -) -> Result, Error> { +) -> Result, Error> { voters = voters .into_iter() .filter(|v| v.hir.voting_power > 0.into()) @@ -120,7 +120,7 @@ mod tests { #[proptest] fn test_insufficient_voters( - #[strategy(vec(any::(), 1..100))] voters: Vec, + #[strategy(vec(any::(), 1..100))] voters: Vec, ) { let cap = Fraction::new(1u64, voters.len() as u64 + 1); assert!(cap_voting_influence(voters, cap).is_err()); @@ -128,8 +128,8 @@ mod tests { #[proptest] fn test_exact_voters( - #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 1..100))] - voters: Vec, + #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 1..100))] + voters: Vec, ) { let cap = Fraction::new(1u64, voters.len() as u64); let min = voters.iter().map(|v| v.hir.voting_power).min().unwrap(); @@ -142,8 +142,8 @@ mod tests { #[proptest] fn test_exact_voters_fixed_at_threshold( - #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 101..=101))] - voters: Vec, + #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 101..=101))] + voters: Vec, ) { let cap = Fraction::new(999u64, 100000u64); let res = cap_voting_influence(voters, cap).unwrap(); @@ -159,8 +159,8 @@ mod tests { #[proptest] fn test_exact_voters_fixed_below_threshold( - #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 100..=100))] - voters: Vec, + #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 100..=100))] + voters: Vec, ) { let cap = Fraction::new(9u64, 1000u64); assert!(cap_voting_influence(voters, cap).is_err()); @@ -168,8 +168,8 @@ mod tests { #[proptest] fn test_below_threshold( - #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 100..300))] - voters: Vec, + #[strategy(vec(any_with::((Default::default(), DEFAULT_VP_STRATEGY)), 100..300))] + voters: Vec, ) { let cap = Fraction::new(1u64, 100u64); let res = cap_voting_influence(voters, cap).unwrap(); @@ -183,7 +183,7 @@ mod tests { } } - impl Arbitrary for VoterEntry { + impl Arbitrary for SnapshotInfo { type Parameters = (String, VpRange); type Strategy = BoxedStrategy; diff --git a/catalyst-toolbox/src/snapshot/mod.rs b/catalyst-toolbox/src/snapshot/mod.rs index 3c90e493..33c646f5 100644 --- a/catalyst-toolbox/src/snapshot/mod.rs +++ b/catalyst-toolbox/src/snapshot/mod.rs @@ -7,7 +7,7 @@ use voting_group::VotingGroupAssigner; use fraction::Fraction; use jormungandr_lib::{crypto::account::Identifier, interfaces::Value}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{borrow::Borrow, collections::BTreeMap, iter::Iterator, num::NonZeroU64}; use thiserror::Error; use voting_hir::VoterHIR; @@ -32,23 +32,27 @@ pub enum Error { } /// Contribution to a voting key for some registration -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KeyContribution { pub reward_address: MainnetRewardAddress, pub value: u64, } -#[derive(Clone, Debug, PartialEq)] -pub struct VoterEntry { - contributions: Vec, - hir: VoterHIR, +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SnapshotInfo { + /// The values in the contributions are the original values in the registration transactions and + /// thus retain the original proportions. + /// However, it's possible that the sum of those values is greater than the voting power assigned in the + /// VoterHIR, due to voting power caps or additional transformations. + pub contributions: Vec, + pub hir: VoterHIR, } #[derive(Clone, Debug, PartialEq)] pub struct Snapshot { // a raw public key is preferred so that we don't have to worry about discrimination when deserializing from // a CIP-36 compatible encoding - inner: BTreeMap, + inner: BTreeMap, stake_threshold: Value, } @@ -114,7 +118,7 @@ impl Snapshot { }); let entries = raw_contribs .into_iter() - .map(|(k, contributions)| VoterEntry { + .map(|(k, contributions)| SnapshotInfo { hir: VoterHIR { voting_group: voting_group_assigner.assign(&k), voting_key: k, @@ -123,6 +127,7 @@ impl Snapshot { contributions, }) .collect(); + dbg!(&entries); Ok(Self { inner: Self::apply_voting_power_cap(entries, cap)? .into_iter() @@ -133,9 +138,9 @@ impl Snapshot { } fn apply_voting_power_cap( - voters: Vec, + voters: Vec, cap: Fraction, - ) -> Result, Error> { + ) -> Result, Error> { Ok(influence_cap::cap_voting_influence(voters, cap)? .into_iter() .collect()) @@ -152,6 +157,10 @@ impl Snapshot { .collect::>() } + pub fn to_full_snapshot_info(&self) -> Vec { + self.inner.values().cloned().collect() + } + pub fn voting_keys(&self) -> impl Iterator { self.inner.keys() }