diff --git a/.changelog/unreleased/features/1992-pos-rewards.md b/.changelog/unreleased/features/1992-pos-rewards.md new file mode 100644 index 0000000000..6cacebc443 --- /dev/null +++ b/.changelog/unreleased/features/1992-pos-rewards.md @@ -0,0 +1,2 @@ +- Implements a claim-based rewards system for PoS inflation. + ([\#1992](https://github.com/anoma/namada/pull/1992)) \ No newline at end of file diff --git a/.github/workflows/scripts/e2e.json b/.github/workflows/scripts/e2e.json index f3792dedbd..909fa32c36 100644 --- a/.github/workflows/scripts/e2e.json +++ b/.github/workflows/scripts/e2e.json @@ -20,6 +20,7 @@ "e2e::ledger_tests::test_node_connectivity_and_consensus": 28, "e2e::ledger_tests::test_epoch_sleep": 12, "e2e::ledger_tests::wrapper_disposable_signer": 28, + "e2e::ledger_tests::pos_rewards": 44, "e2e::wallet_tests::wallet_address_cmds": 1, "e2e::wallet_tests::wallet_encrypted_key_cmds": 1, "e2e::wallet_tests::wallet_encrypted_key_cmds_env_var": 1, diff --git a/apps/src/lib/bench_utils.rs b/apps/src/lib/bench_utils.rs index 4f12c5b6b1..b86bf5f442 100644 --- a/apps/src/lib/bench_utils.rs +++ b/apps/src/lib/bench_utils.rs @@ -108,6 +108,7 @@ pub const TX_CHANGE_VALIDATOR_COMMISSION_WASM: &str = pub const TX_IBC_WASM: &str = "tx_ibc.wasm"; pub const TX_UNJAIL_VALIDATOR_WASM: &str = "tx_unjail_validator.wasm"; pub const TX_WITHDRAW_WASM: &str = "tx_withdraw.wasm"; +pub const TX_CLAIM_REWARDS_WASM: &str = "tx_claim_rewards.wasm"; pub const TX_INIT_ACCOUNT_WASM: &str = "tx_init_account.wasm"; pub const TX_INIT_VALIDATOR_WASM: &str = "tx_init_validator.wasm"; diff --git a/apps/src/lib/cli.rs b/apps/src/lib/cli.rs index b9b4d15780..b4125c404c 100644 --- a/apps/src/lib/cli.rs +++ b/apps/src/lib/cli.rs @@ -229,6 +229,7 @@ pub mod cmds { .subcommand(Unbond::def().display_order(2)) .subcommand(Withdraw::def().display_order(2)) .subcommand(Redelegate::def().display_order(2)) + .subcommand(ClaimRewards::def().display_order(2)) .subcommand(TxCommissionRateChange::def().display_order(2)) // Ethereum bridge transactions .subcommand(AddToEthBridgePool::def().display_order(3)) @@ -288,6 +289,7 @@ pub mod cmds { let unbond = Self::parse_with_ctx(matches, Unbond); let withdraw = Self::parse_with_ctx(matches, Withdraw); let redelegate = Self::parse_with_ctx(matches, Redelegate); + let claim_rewards = Self::parse_with_ctx(matches, ClaimRewards); let query_epoch = Self::parse_with_ctx(matches, QueryEpoch); let query_account = Self::parse_with_ctx(matches, QueryAccount); let query_transfers = Self::parse_with_ctx(matches, QueryTransfers); @@ -334,6 +336,7 @@ pub mod cmds { .or(unbond) .or(withdraw) .or(redelegate) + .or(claim_rewards) .or(add_to_eth_bridge_pool) .or(tx_update_steward_commission) .or(tx_resign_steward) @@ -409,6 +412,7 @@ pub mod cmds { Bond(Bond), Unbond(Unbond), Withdraw(Withdraw), + ClaimRewards(ClaimRewards), Redelegate(Redelegate), AddToEthBridgePool(AddToEthBridgePool), TxUpdateStewardCommission(TxUpdateStewardCommission), @@ -1437,6 +1441,28 @@ pub mod cmds { } } + #[derive(Clone, Debug)] + pub struct ClaimRewards(pub args::ClaimRewards); + + impl SubCmd for ClaimRewards { + const CMD: &'static str = "claim-rewards"; + + fn parse(matches: &ArgMatches) -> Option { + matches + .subcommand_matches(Self::CMD) + .map(|matches| ClaimRewards(args::ClaimRewards::parse(matches))) + } + + fn def() -> App { + App::new(Self::CMD) + .about( + "Claim available rewards tokens from bonds that \ + contributed in consensus.", + ) + .add_args::>() + } + } + #[derive(Clone, Debug)] pub struct Redelegate(pub args::Redelegate); @@ -2663,6 +2689,7 @@ pub mod args { "tx_update_steward_commission.wasm"; pub const TX_VOTE_PROPOSAL: &str = "tx_vote_proposal.wasm"; pub const TX_WITHDRAW_WASM: &str = "tx_withdraw.wasm"; + pub const TX_CLAIM_REWARDS_WASM: &str = "tx_claim_rewards.wasm"; pub const TX_RESIGN_STEWARD: &str = "tx_resign_steward.wasm"; pub const VP_USER_WASM: &str = "vp_user.wasm"; @@ -4594,6 +4621,43 @@ pub mod args { } } + impl CliToSdk> for ClaimRewards { + fn to_sdk(self, ctx: &mut Context) -> ClaimRewards { + let tx = self.tx.to_sdk(ctx); + let chain_ctx = ctx.borrow_chain_or_exit(); + ClaimRewards:: { + tx, + validator: chain_ctx.get(&self.validator), + source: self.source.map(|x| chain_ctx.get(&x)), + tx_code_path: self.tx_code_path.to_path_buf(), + } + } + } + + impl Args for ClaimRewards { + fn parse(matches: &ArgMatches) -> Self { + let tx = Tx::parse(matches); + let validator = VALIDATOR.parse(matches); + let source = SOURCE_OPT.parse(matches); + let tx_code_path = PathBuf::from(TX_CLAIM_REWARDS_WASM); + Self { + tx, + validator, + source, + tx_code_path, + } + } + + fn def(app: App) -> App { + app.add_args::>() + .arg(VALIDATOR.def().help("Validator address.")) + .arg(SOURCE_OPT.def().help( + "Source address for claiming rewards for a bond. For \ + self-bonds, the validator is also the source.", + )) + } + } + impl CliToSdk> for QueryConversions { fn to_sdk(self, ctx: &mut Context) -> QueryConversions { QueryConversions:: { diff --git a/apps/src/lib/cli/client.rs b/apps/src/lib/cli/client.rs index 9a88626561..0d6ea9f711 100644 --- a/apps/src/lib/cli/client.rs +++ b/apps/src/lib/cli/client.rs @@ -196,6 +196,17 @@ impl CliApi { let namada = ctx.to_sdk(&client, io); tx::submit_withdraw(&namada, args).await?; } + Sub::ClaimRewards(ClaimRewards(mut args)) => { + let client = client.unwrap_or_else(|| { + C::from_tendermint_address( + &mut args.tx.ledger_address, + ) + }); + client.wait_until_node_is_synced(io).await?; + let args = args.to_sdk(&mut ctx); + let namada = ctx.to_sdk(&client, io); + tx::submit_claim_rewards(&namada, args).await?; + } Sub::Redelegate(Redelegate(mut args)) => { let client = client.unwrap_or_else(|| { C::from_tendermint_address( diff --git a/apps/src/lib/client/tx.rs b/apps/src/lib/client/tx.rs index d939d5691e..557fae14ca 100644 --- a/apps/src/lib/client/tx.rs +++ b/apps/src/lib/client/tx.rs @@ -951,6 +951,28 @@ where Ok(()) } +pub async fn submit_claim_rewards<'a, N: Namada<'a>>( + namada: &N, + args: args::ClaimRewards, +) -> Result<(), error::Error> +where + ::Error: std::fmt::Display, +{ + let (mut tx, signing_data, _fee_unshield_epoch) = + args.build(namada).await?; + signing::generate_test_vector(namada, &tx).await?; + + if args.tx.dump_tx { + tx::dump_tx(namada.io(), &args.tx, tx); + } else { + namada.sign(&mut tx, &args.tx, signing_data).await?; + + namada.submit(tx, &args.tx).await?; + } + + Ok(()) +} + pub async fn submit_redelegate<'a, N: Namada<'a>>( namada: &N, args: args::Redelegate, diff --git a/apps/src/lib/node/ledger/shell/finalize_block.rs b/apps/src/lib/node/ledger/shell/finalize_block.rs index 2202f7a157..9c9a30d315 100644 --- a/apps/src/lib/node/ledger/shell/finalize_block.rs +++ b/apps/src/lib/node/ledger/shell/finalize_block.rs @@ -1,7 +1,5 @@ //! Implementation of the `FinalizeBlock` ABCI++ method for the Shell -use std::collections::HashMap; - use data_encoding::HEXUPPER; use namada::core::ledger::inflation; use namada::core::ledger::pgf::ADDRESS as pgf_address; @@ -15,17 +13,12 @@ use namada::ledger::storage::EPOCH_SWITCH_BLOCKS_DELAY; use namada::ledger::storage_api::token::credit_tokens; use namada::ledger::storage_api::{pgf, StorageRead, StorageWrite}; use namada::proof_of_stake::{ - delegator_rewards_products_handle, find_validator_by_raw_hash, - read_last_block_proposer_address, read_pos_params, read_total_stake, - read_validator_stake, rewards_accumulator_handle, - validator_commission_rate_handle, validator_rewards_products_handle, - write_last_block_proposer_address, + find_validator_by_raw_hash, read_last_block_proposer_address, + read_pos_params, read_total_stake, write_last_block_proposer_address, }; -use namada::types::address::Address; use namada::types::dec::Dec; use namada::types::key::tm_raw_hash_to_string; use namada::types::storage::{BlockHash, BlockResults, Epoch, Header}; -use namada::types::token::Amount; use namada::types::transaction::protocol::{ ethereum_tx_data_variants, ProtocolTxType, }; @@ -124,9 +117,6 @@ where // because it potentially needs to be able to read validator state from // previous epoch and jailing validator removes the historical state self.log_block_rewards(&req.votes, height, current_epoch, new_epoch)?; - if new_epoch { - self.apply_inflation(current_epoch)?; - } // Invariant: This has to be applied after // `copy_validator_sets_and_positions` and before `self.update_epoch`. @@ -134,7 +124,10 @@ where // Invariant: This has to be applied after // `copy_validator_sets_and_positions` if we're starting a new epoch if new_epoch { + // Invariant: Process slashes before inflation as they may affect + // the rewards in the current epoch. self.process_slashes(); + self.apply_inflation(current_epoch)?; } let mut stats = InternalStats::default(); @@ -680,99 +673,21 @@ where self.wl_storage.storage.block.height.0 - first_block_of_last_epoch }; - // Read the rewards accumulator and calculate the new rewards products - // for the previous epoch - // - // TODO: think about changing the reward to Decimal - let inflation = token::Amount::from_uint(inflation, 0) - .expect("Should not fail Uint -> Amount conversion"); - - let mut reward_tokens_remaining = inflation; - let mut new_rewards_products: HashMap = - HashMap::new(); - for acc in rewards_accumulator_handle().iter(&self.wl_storage)? { - let (address, value) = acc?; - - // Get reward token amount for this validator - let fractional_claim = value / num_blocks_in_last_epoch; - let reward = fractional_claim * inflation; - - // Get validator data at the last epoch - let stake = Dec::from(read_validator_stake( - &self.wl_storage, - ¶ms, - &address, - last_epoch, - )?); - let last_rewards_product = - validator_rewards_products_handle(&address) - .get(&self.wl_storage, &last_epoch)? - .unwrap_or_else(Dec::one); - let last_delegation_product = - delegator_rewards_products_handle(&address) - .get(&self.wl_storage, &last_epoch)? - .unwrap_or_else(Dec::one); - let commission_rate = validator_commission_rate_handle(&address) - .get(&self.wl_storage, last_epoch, ¶ms)? - .expect("Should be able to find validator commission rate"); - - let new_product = - last_rewards_product * (Dec::one() + Dec::from(reward) / stake); - let new_delegation_product = last_delegation_product - * (Dec::one() - + (Dec::one() - commission_rate) * Dec::from(reward) - / stake); - new_rewards_products - .insert(address, (new_product, new_delegation_product)); - reward_tokens_remaining -= reward; - } - for ( - address, - (new_validator_reward_product, new_delegator_reward_product), - ) in new_rewards_products - { - validator_rewards_products_handle(&address).insert( - &mut self.wl_storage, - last_epoch, - new_validator_reward_product, - )?; - delegator_rewards_products_handle(&address).insert( - &mut self.wl_storage, - last_epoch, - new_delegator_reward_product, - )?; - } - let staking_token = staking_token_address(&self.wl_storage); - // Mint tokens to the PoS account for the last epoch's inflation - let pos_reward_tokens = inflation - reward_tokens_remaining; - tracing::info!( - "Minting tokens for PoS rewards distribution into the PoS \ - account. Amount: {}.", - pos_reward_tokens.to_string_native(), - ); - credit_tokens( + let inflation = token::Amount::from_uint(inflation, 0) + .expect("Should not fail Uint -> Amount conversion"); + namada_proof_of_stake::update_rewards_products_and_mint_inflation( &mut self.wl_storage, + ¶ms, + last_epoch, + num_blocks_in_last_epoch, + inflation, &staking_token, - &address::POS, - pos_reward_tokens, - )?; - - if reward_tokens_remaining > token::Amount::zero() { - let amount = Amount::from_uint(reward_tokens_remaining, 0).unwrap(); - tracing::info!( - "Minting tokens remaining from PoS rewards distribution into \ - the Governance account. Amount: {}.", - amount.to_string_native() - ); - credit_tokens( - &mut self.wl_storage, - &staking_token, - &address::GOV, - amount, - )?; - } + ) + .expect( + "Must be able to update PoS rewards products and mint inflation", + ); // Write new rewards parameters that will be used for the inflation of // the current new epoch @@ -783,17 +698,6 @@ where .write(¶ms_storage::get_staked_ratio_key(), locked_ratio) .expect("unable to write new locked ratio"); - // Delete the accumulators from storage - // TODO: refactor with https://github.com/anoma/namada/issues/1225 - let addresses_to_drop: HashSet
= rewards_accumulator_handle() - .iter(&self.wl_storage)? - .map(|a| a.unwrap().0) - .collect(); - for address in addresses_to_drop.into_iter() { - rewards_accumulator_handle() - .remove(&mut self.wl_storage, &address)?; - } - // Pgf inflation let pgf_parameters = pgf::get_parameters(&self.wl_storage)?; @@ -1014,8 +918,9 @@ fn pos_votes_from_abci( /// are covered by the e2e tests. #[cfg(test)] mod test_finalize_block { - use std::collections::{BTreeMap, BTreeSet}; + use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::num::NonZeroU64; + use std::str::FromStr; use data_encoding::HEXUPPER; use namada::core::ledger::eth_bridge::storage::wrapped_erc20s; @@ -1045,9 +950,10 @@ mod test_finalize_block { use namada::proof_of_stake::{ enqueued_slashes_handle, get_num_consensus_validators, read_consensus_validator_set_addresses_with_stake, - rewards_accumulator_handle, unjail_validator, + read_validator_stake, rewards_accumulator_handle, unjail_validator, validator_consensus_key_handle, validator_rewards_products_handle, validator_slashes_handle, validator_state_handle, write_pos_params, + ADDRESS as pos_address, }; use namada::proto::{Code, Data, Section, Signature}; use namada::types::dec::POS_DECIMAL_PRECISION; @@ -1078,7 +984,6 @@ mod test_finalize_block { use crate::node::ledger::shims::abcipp_shim_types::shim::request::{ FinalizeBlock, ProcessedTx, }; - const GAS_LIMIT_MULTIPLIER: u64 = 300_000; /// Make a wrapper tx and a processed tx from the wrapped tx that can be @@ -2205,6 +2110,372 @@ mod test_finalize_block { assert!(rp3 > rp4); } + /// A unit test for PoS inflationary rewards claiming + #[test] + fn test_claim_rewards() { + let (mut shell, _recv, _, _) = setup_with_cfg(SetupCfg { + last_height: 0, + num_validators: 1, + ..Default::default() + }); + + let mut validator_set: BTreeSet = + read_consensus_validator_set_addresses_with_stake( + &shell.wl_storage, + Epoch::default(), + ) + .unwrap() + .into_iter() + .collect(); + + let params = read_pos_params(&shell.wl_storage).unwrap(); + + let validator = validator_set.pop_first().unwrap(); + + let get_pkh = |address, epoch| { + let ck = validator_consensus_key_handle(&address) + .get(&shell.wl_storage, epoch, ¶ms) + .unwrap() + .unwrap(); + let hash_string = tm_consensus_key_raw_hash(&ck); + HEXUPPER.decode(hash_string.as_bytes()).unwrap() + }; + + let pkh1 = get_pkh(validator.address.clone(), Epoch::default()); + let votes = vec![VoteInfo { + validator: Some(Validator { + address: pkh1.clone(), + power: u128::try_from(validator.bonded_stake) + .expect("Test failed") as i64, + }), + signed_last_block: true, + }]; + // let rewards_prod_1 = + // validator_rewards_products_handle(&val1.address); + + let is_reward_equal_enough = |expected: token::Amount, + actual: token::Amount, + tolerance: u64| + -> bool { + let diff = expected - actual; + diff <= tolerance.into() + }; + + let bond_id = BondId { + source: validator.address.clone(), + validator: validator.address.clone(), + }; + let init_stake = validator.bonded_stake; + + let mut total_rewards = token::Amount::zero(); + let mut total_claimed = token::Amount::zero(); + + // FINALIZE BLOCK 1. Tell Namada that val1 is the block proposer. We + // won't receive votes from TM since we receive votes at a 1-block + // delay, so votes will be empty here + next_block_for_inflation(&mut shell, pkh1.clone(), vec![], None); + assert!( + rewards_accumulator_handle() + .is_empty(&shell.wl_storage) + .unwrap() + ); + + let (current_epoch, inflation) = + advance_epoch(&mut shell, &pkh1, &votes, None); + total_rewards += inflation; + + // Claim the rewards from the initial epoch + let reward_1 = namada_proof_of_stake::claim_reward_tokens( + &mut shell.wl_storage, + None, + &validator.address, + current_epoch, + ) + .unwrap(); + total_claimed += reward_1; + assert!(is_reward_equal_enough(total_rewards, total_claimed, 1)); + + // Try a claim the next block and ensure we get 0 tokens back + next_block_for_inflation(&mut shell, pkh1.clone(), votes.clone(), None); + let att = namada_proof_of_stake::claim_reward_tokens( + &mut shell.wl_storage, + None, + &validator.address, + current_epoch, + ) + .unwrap(); + assert_eq!(att, token::Amount::zero()); + + // Go to the next epoch + let (current_epoch, inflation) = + advance_epoch(&mut shell, &pkh1, &votes, None); + total_rewards += inflation; + + // Unbond some tokens + let unbond_amount = token::Amount::native_whole(50_000); + let unbond_res = namada_proof_of_stake::unbond_tokens( + &mut shell.wl_storage, + None, + &validator.address, + unbond_amount, + current_epoch, + false, + ) + .unwrap(); + assert_eq!(unbond_res.sum, unbond_amount); + + let rew = namada_proof_of_stake::claim_reward_tokens( + &mut shell.wl_storage, + None, + &validator.address, + current_epoch, + ) + .unwrap(); + total_claimed += rew; + assert!(is_reward_equal_enough(total_rewards, total_claimed, 3)); + + // Check the bond amounts for rewards up thru the withdrawable epoch + let withdraw_epoch = current_epoch + params.withdrawable_epoch_offset(); + let last_claim_epoch = + namada_proof_of_stake::get_last_reward_claim_epoch( + &shell.wl_storage, + &validator.address, + &validator.address, + ) + .unwrap(); + let bond_amounts = namada_proof_of_stake::bond_amounts_for_rewards( + &shell.wl_storage, + &bond_id, + last_claim_epoch.unwrap_or_default(), + withdraw_epoch, + ) + .unwrap(); + + // Should only have the remaining amounts in bonds themselves + let mut exp_bond_amounts = BTreeMap::::new(); + for epoch in Epoch::iter_bounds_inclusive( + last_claim_epoch.unwrap_or_default(), + withdraw_epoch, + ) { + exp_bond_amounts + .insert(epoch, validator.bonded_stake - unbond_amount); + } + assert_eq!(exp_bond_amounts, bond_amounts); + + let pipeline_epoch_from_unbond = current_epoch + params.pipeline_len; + + // Advance to the withdrawable epoch + let mut current_epoch = current_epoch; + let mut missed_rewards = token::Amount::zero(); + while current_epoch < withdraw_epoch { + let votes = get_default_true_votes( + &shell.wl_storage, + shell.wl_storage.storage.block.epoch, + ); + let (new_epoch, inflation) = + advance_epoch(&mut shell, &pkh1, &votes, None); + current_epoch = new_epoch; + + total_rewards += inflation; + if current_epoch <= pipeline_epoch_from_unbond { + missed_rewards += inflation; + } + } + + // Withdraw tokens + let withdraw_amount = namada_proof_of_stake::withdraw_tokens( + &mut shell.wl_storage, + None, + &validator.address, + current_epoch, + ) + .unwrap(); + assert_eq!(withdraw_amount, unbond_amount); + + // Claim tokens + let reward_2 = namada_proof_of_stake::claim_reward_tokens( + &mut shell.wl_storage, + None, + &validator.address, + current_epoch, + ) + .unwrap(); + total_claimed += reward_2; + + // The total rewards claimed should be approximately equal to the total + // minted inflation, minus (unbond_amount / initial_stake) * rewards + // from the unbond epoch and the following epoch (the missed_rewards) + let ratio = Dec::from(unbond_amount) / Dec::from(init_stake); + let lost_rewards = ratio * missed_rewards; + let uncertainty = Dec::from_str("0.07").unwrap(); + let token_uncertainty = uncertainty * lost_rewards; + let token_diff = total_claimed + lost_rewards - total_rewards; + assert!(token_diff < token_uncertainty); + } + + /// A unit test for PoS inflationary rewards claiming + #[test] + fn test_claim_validator_commissions() { + let (mut shell, _recv, _, _) = setup_with_cfg(SetupCfg { + last_height: 0, + num_validators: 1, + ..Default::default() + }); + + let mut validator_set: BTreeSet = + read_consensus_validator_set_addresses_with_stake( + &shell.wl_storage, + Epoch::default(), + ) + .unwrap() + .into_iter() + .collect(); + + let params = read_pos_params(&shell.wl_storage).unwrap(); + + let validator = validator_set.pop_first().unwrap(); + let commission_rate = + namada_proof_of_stake::validator_commission_rate_handle( + &validator.address, + ) + .get(&shell.wl_storage, Epoch(0), ¶ms) + .unwrap() + .unwrap(); + + let get_pkh = |address, epoch| { + let ck = validator_consensus_key_handle(&address) + .get(&shell.wl_storage, epoch, ¶ms) + .unwrap() + .unwrap(); + let hash_string = tm_consensus_key_raw_hash(&ck); + HEXUPPER.decode(hash_string.as_bytes()).unwrap() + }; + + let pkh1 = get_pkh(validator.address.clone(), Epoch::default()); + + let is_reward_equal_enough = |expected: token::Amount, + actual: token::Amount, + tolerance: u64| + -> bool { + let diff = expected - actual; + diff <= tolerance.into() + }; + + let init_stake = validator.bonded_stake; + + let mut total_rewards = token::Amount::zero(); + let mut total_claimed = token::Amount::zero(); + + // FINALIZE BLOCK 1. Tell Namada that val1 is the block proposer. We + // won't receive votes from TM since we receive votes at a 1-block + // delay, so votes will be empty here + next_block_for_inflation(&mut shell, pkh1.clone(), vec![], None); + assert!( + rewards_accumulator_handle() + .is_empty(&shell.wl_storage) + .unwrap() + ); + + // Make an account with balance and delegate some tokens + let delegator = address::testing::gen_implicit_address(); + let del_amount = init_stake; + let staking_token = shell.wl_storage.storage.native_token.clone(); + credit_tokens( + &mut shell.wl_storage, + &staking_token, + &delegator, + 2 * init_stake, + ) + .unwrap(); + let mut current_epoch = shell.wl_storage.storage.block.epoch; + namada_proof_of_stake::bond_tokens( + &mut shell.wl_storage, + Some(&delegator), + &validator.address, + del_amount, + current_epoch, + None, + ) + .unwrap(); + + // Advance to pipeline epoch + for _ in 0..params.pipeline_len { + let votes = get_default_true_votes( + &shell.wl_storage, + shell.wl_storage.storage.block.epoch, + ); + let (new_epoch, inflation) = + advance_epoch(&mut shell, &pkh1, &votes, None); + current_epoch = new_epoch; + total_rewards += inflation; + } + + // Claim the rewards for the validator for the first two epochs + let val_reward_1 = namada_proof_of_stake::claim_reward_tokens( + &mut shell.wl_storage, + None, + &validator.address, + current_epoch, + ) + .unwrap(); + total_claimed += val_reward_1; + assert!(is_reward_equal_enough( + total_rewards, + total_claimed, + current_epoch.0 + )); + + // Go to the next epoch, where now the delegator's stake has been active + // for an epoch + let votes = get_default_true_votes( + &shell.wl_storage, + shell.wl_storage.storage.block.epoch, + ); + let (new_epoch, inflation_3) = + advance_epoch(&mut shell, &pkh1, &votes, None); + current_epoch = new_epoch; + total_rewards += inflation_3; + + // Claim again for the validator + let val_reward_2 = namada_proof_of_stake::claim_reward_tokens( + &mut shell.wl_storage, + None, + &validator.address, + current_epoch, + ) + .unwrap(); + + // Claim for the delegator + let del_reward_1 = namada_proof_of_stake::claim_reward_tokens( + &mut shell.wl_storage, + Some(&delegator), + &validator.address, + current_epoch, + ) + .unwrap(); + + // Check that both claims add up to the inflation minted in the last + // epoch + assert!(is_reward_equal_enough( + inflation_3, + val_reward_2 + del_reward_1, + current_epoch.0 + )); + + // Check that the commission earned is expected + let del_stake = Dec::from(del_amount); + let tot_stake = Dec::from(init_stake + del_amount); + let stake_ratio = del_stake / tot_stake; + let del_rewards_no_commission = stake_ratio * inflation_3; + let commission = commission_rate * del_rewards_no_commission; + let exp_val_reward = + (Dec::one() - stake_ratio) * inflation_3 + commission; + let exp_del_reward = del_rewards_no_commission - commission; + + assert_eq!(exp_val_reward, val_reward_2); + assert_eq!(exp_del_reward, del_reward_1); + } + fn get_rewards_acc(storage: &S) -> HashMap where S: StorageRead, @@ -3069,7 +3340,7 @@ mod test_finalize_block { // Advance to epoch 1 and // 1. Delegate 67231 NAM to validator // 2. Validator self-unbond 154654 NAM - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); assert_eq!(shell.wl_storage.storage.block.epoch.0, 1_u64); // Make an account with balance and delegate some tokens @@ -3135,7 +3406,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); println!("\nUnbonding in epoch 2"); let del_unbond_1_amount = token::Amount::native_whole(18_000); namada_proof_of_stake::unbond_tokens( @@ -3180,7 +3451,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); println!("\nBonding in epoch 3"); let self_bond_1_amount = token::Amount::native_whole(9_123); @@ -3200,7 +3471,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); assert_eq!(current_epoch.0, 4_u64); let self_unbond_2_amount = token::Amount::native_whole(15_000); @@ -3220,7 +3491,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); assert_eq!(current_epoch.0, 5_u64); println!("Delegating in epoch 5"); @@ -3243,7 +3514,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); assert_eq!(current_epoch.0, 6_u64); // Discover a misbehavior committed in epoch 3 @@ -3308,7 +3579,7 @@ mod test_finalize_block { println!("Advancing to epoch 7"); // Advance to epoch 7 - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); // Discover two more misbehaviors, one committed in epoch 3, one in // epoch 4 @@ -3412,7 +3683,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); assert_eq!(current_epoch.0, 9_u64); let val_stake_3 = namada_proof_of_stake::read_validator_stake( @@ -3540,7 +3811,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - let current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + let (current_epoch, _) = advance_epoch(&mut shell, &pkh1, &votes, None); assert_eq!(current_epoch.0, 10_u64); // Check the balance of the Slash Pool @@ -3903,7 +4174,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None).0; } assert_eq!(shell.wl_storage.storage.block.epoch.0, default_past_epochs); assert_eq!(current_epoch.0, default_past_epochs); @@ -3920,7 +4191,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None).0; assert_eq!(current_epoch.0, default_past_epochs + 1); check_is_data( @@ -3959,7 +4230,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None).0; if current_epoch.0 == consensus_val_set_len + 1 { break; } @@ -3977,7 +4248,7 @@ mod test_finalize_block { &shell.wl_storage, shell.wl_storage.storage.block.epoch, ); - current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None); + current_epoch = advance_epoch(&mut shell, &pkh1, &votes, None).0; for ep in Epoch::default().iter_range(2) { assert!( consensus_val_set @@ -4032,8 +4303,21 @@ mod test_finalize_block { proposer_address: &[u8], consensus_votes: &[VoteInfo], misbehaviors: Option>, - ) -> Epoch { + ) -> (Epoch, token::Amount) { let current_epoch = shell.wl_storage.storage.block.epoch; + let staking_token = staking_token_address(&shell.wl_storage); + + // NOTE: assumed that the only change in pos address balance by + // advancing to the next epoch is minted inflation - no change occurs + // due to slashing + let pos_balance_pre = shell + .wl_storage + .read::(&token::balance_key( + &staking_token, + &pos_address, + )) + .unwrap() + .unwrap_or_default(); loop { next_block_for_inflation( shell, @@ -4045,7 +4329,19 @@ mod test_finalize_block { break; } } - shell.wl_storage.storage.block.epoch + let pos_balance_post = shell + .wl_storage + .read::(&token::balance_key( + &staking_token, + &pos_address, + )) + .unwrap() + .unwrap_or_default(); + + ( + shell.wl_storage.storage.block.epoch, + pos_balance_post - pos_balance_pre, + ) } /// Test that updating the ethereum bridge params via governance works. diff --git a/benches/txs.rs b/benches/txs.rs index ac24b6ab4f..d5ded4d601 100644 --- a/benches/txs.rs +++ b/benches/txs.rs @@ -27,11 +27,11 @@ use namada::types::transaction::EllipticCurve; use namada_apps::bench_utils::{ generate_ibc_transfer_tx, generate_tx, BenchShell, BenchShieldedCtx, ALBERT_PAYMENT_ADDRESS, ALBERT_SPENDING_KEY, BERTHA_PAYMENT_ADDRESS, - TX_BOND_WASM, TX_CHANGE_VALIDATOR_COMMISSION_WASM, TX_INIT_ACCOUNT_WASM, - TX_INIT_PROPOSAL_WASM, TX_INIT_VALIDATOR_WASM, TX_REDELEGATE_WASM, - TX_REVEAL_PK_WASM, TX_UNBOND_WASM, TX_UNJAIL_VALIDATOR_WASM, - TX_UPDATE_ACCOUNT_WASM, TX_VOTE_PROPOSAL_WASM, TX_WITHDRAW_WASM, - VP_VALIDATOR_WASM, + TX_BOND_WASM, TX_CHANGE_VALIDATOR_COMMISSION_WASM, TX_CLAIM_REWARDS_WASM, + TX_INIT_ACCOUNT_WASM, TX_INIT_PROPOSAL_WASM, TX_INIT_VALIDATOR_WASM, + TX_REDELEGATE_WASM, TX_REVEAL_PK_WASM, TX_UNBOND_WASM, + TX_UNJAIL_VALIDATOR_WASM, TX_UPDATE_ACCOUNT_WASM, TX_VOTE_PROPOSAL_WASM, + TX_WITHDRAW_WASM, VP_VALIDATOR_WASM, }; use namada_apps::wallet::defaults; use rand::rngs::StdRng; @@ -718,6 +718,61 @@ fn unjail_validator(c: &mut Criterion) { }); } +fn claim_rewards(c: &mut Criterion) { + let mut group = c.benchmark_group("claim_rewards"); + + let claim = generate_tx( + TX_CLAIM_REWARDS_WASM, + Withdraw { + validator: defaults::validator_address(), + source: Some(defaults::albert_address()), + }, + None, + None, + Some(&defaults::albert_keypair()), + ); + + let self_claim = generate_tx( + TX_CLAIM_REWARDS_WASM, + Withdraw { + validator: defaults::validator_address(), + source: None, + }, + None, + None, + Some(&defaults::validator_keypair()), + ); + + for (signed_tx, bench_name) in + [claim, self_claim].iter().zip(["claim", "self_claim"]) + { + group.bench_function(bench_name, |b| { + b.iter_batched_ref( + || { + let mut shell = BenchShell::default(); + + // Advance Epoch for pipeline and unbonding length + let params = + proof_of_stake::read_pos_params(&shell.wl_storage) + .unwrap(); + let advance_epochs = + params.pipeline_len + params.unbonding_len; + + for _ in 0..=advance_epochs { + shell.advance_epoch(); + } + + shell + }, + |shell| shell.execute_tx(signed_tx), + criterion::BatchSize::LargeInput, + ) + }); + } + + group.finish(); +} + criterion_group!( whitelisted_txs, transfer, @@ -733,6 +788,7 @@ criterion_group!( init_validator, change_validator_commission, ibc, - unjail_validator + unjail_validator, + claim_rewards ); criterion_main!(whitelisted_txs); diff --git a/core/src/ledger/inflation.rs b/core/src/ledger/inflation.rs index 477eb7ade9..f9c9e8d624 100644 --- a/core/src/ledger/inflation.rs +++ b/core/src/ledger/inflation.rs @@ -234,4 +234,90 @@ mod test { assert_eq!(locked_ratio_2, locked_ratio_1); assert_eq!(inflation_2, Uint::zero()); } + + #[test] + fn test_inflation_playground() { + let init_locked_ratio = Dec::from_str("0.1").unwrap(); + let total_tokens = 1_000_000_000_000_000_u64; + let epochs_per_year = 365_u64; + + let staking_growth = Dec::from_str("0.04").unwrap(); + // let mut do_add = true; + + // let a = (init_locked_ratio * total_tokens).to_uint().unwrap(); + let num_rounds = 100; + + let mut controller = RewardsController { + locked_tokens: (init_locked_ratio * total_tokens) + .to_uint() + .unwrap(), + total_tokens: Uint::from(total_tokens), + total_native_tokens: Uint::from(total_tokens), + locked_ratio_target: Dec::from_str("0.66666666").unwrap(), + locked_ratio_last: init_locked_ratio, + max_reward_rate: Dec::from_str("0.1").unwrap(), + last_inflation_amount: Uint::zero(), + p_gain_nom: Dec::from_str("0.25").unwrap(), + d_gain_nom: Dec::from_str("0.25").unwrap(), + epochs_per_year, + }; + dbg!(&controller); + + for round in 0..num_rounds { + let ValsToUpdate { + locked_ratio, + inflation, + } = controller.clone().run(); + let rate = Dec::try_from(inflation).unwrap() + * Dec::from(epochs_per_year) + / Dec::from(total_tokens); + println!( + "Round {round}: Locked ratio: {locked_ratio}, inflation rate: \ + {rate}", + ); + controller.last_inflation_amount = inflation; + controller.total_tokens += inflation; + controller.total_native_tokens += inflation; + + // if rate.abs_diff(&controller.max_reward_rate) + // < Dec::from_str("0.01").unwrap() + // { + // controller.locked_tokens = controller.total_tokens; + // } + + let tot_tokens = u64::try_from(controller.total_tokens).unwrap(); + let change_staked_tokens = + (staking_growth * tot_tokens).to_uint().unwrap(); + controller.locked_tokens = std::cmp::min( + controller.total_tokens, + controller.locked_tokens + change_staked_tokens, + ); + + // if locked_ratio > Dec::from_str("0.8").unwrap() + // && locked_ratio - controller.locked_ratio_last >= Dec::zero() + // { + // do_add = false; + // } else if locked_ratio < Dec::from_str("0.4").unwrap() + // && locked_ratio - controller.locked_ratio_last < Dec::zero() + // { + // do_add = true; + // } + + // controller.locked_tokens = std::cmp::min( + // if do_add { + // controller.locked_tokens + change_staked_tokens + // } else { + // controller.locked_tokens - change_staked_tokens + // }, + // controller.total_tokens, + // ); + + controller.locked_ratio_last = locked_ratio; + } + + // controller.locked_ratio_last = locked_ratio_1; + // controller.last_inflation_amount = inflation_1; + // controller.total_tokens += inflation_1; + // controller.locked_tokens += inflation_1; + } } diff --git a/core/src/types/token.rs b/core/src/types/token.rs index 372cd9a2c0..1b0e7a40d2 100644 --- a/core/src/types/token.rs +++ b/core/src/types/token.rs @@ -1055,9 +1055,9 @@ impl Default for Parameters { fn default() -> Self { Self { max_reward_rate: Dec::from_str("0.1").unwrap(), - kp_gain_nom: Dec::from_str("0.1").unwrap(), - kd_gain_nom: Dec::from_str("0.1").unwrap(), - locked_ratio_target: Dec::from_str("0.1").unwrap(), + kp_gain_nom: Dec::from_str("0.25").unwrap(), + kd_gain_nom: Dec::from_str("0.25").unwrap(), + locked_ratio_target: Dec::from_str("0.6667").unwrap(), } } } diff --git a/core/src/types/transaction/pos.rs b/core/src/types/transaction/pos.rs index e3ea9d3a21..1dd97e1059 100644 --- a/core/src/types/transaction/pos.rs +++ b/core/src/types/transaction/pos.rs @@ -95,6 +95,27 @@ pub struct Withdraw { pub source: Option
, } +/// A claim of pending rewards. +#[derive( + Debug, + Clone, + PartialEq, + BorshSerialize, + BorshDeserialize, + BorshSchema, + Hash, + Eq, + Serialize, + Deserialize, +)] +pub struct ClaimRewards { + /// Validator address + pub validator: Address, + /// Source address for claiming rewards from a bond. For self-bonds, the + /// validator is also the source + pub source: Option
, +} + /// A redelegation of bonded tokens from one validator to another. #[derive( Debug, diff --git a/proof_of_stake/src/lib.rs b/proof_of_stake/src/lib.rs index f977ff4a0c..1759ba22e6 100644 --- a/proof_of_stake/src/lib.rs +++ b/proof_of_stake/src/lib.rs @@ -37,7 +37,7 @@ use namada_core::ledger::storage_api::collections::{LazyCollection, LazySet}; use namada_core::ledger::storage_api::{ self, governance, token, ResultExt, StorageRead, StorageWrite, }; -use namada_core::types::address::{Address, InternalAddress}; +use namada_core::types::address::{self, Address, InternalAddress}; use namada_core::types::dec::Dec; use namada_core::types::key::{ common, protocol_pk_key, tm_consensus_key_raw_hash, PublicKeyTmRawHash, @@ -49,7 +49,8 @@ use rewards::PosRewardsCalculator; use storage::{ bonds_for_source_prefix, bonds_prefix, consensus_keys_key, get_validator_address_from_bond, is_bond_key, is_unbond_key, - is_validator_slashes_key, last_block_proposer_key, params_key, + is_validator_slashes_key, last_block_proposer_key, + last_pos_reward_claim_epoch_key, params_key, rewards_counter_key, slashes_prefix, unbonds_for_source_prefix, unbonds_prefix, validator_address_raw_hash_key, validator_last_slash_key, validator_max_commission_rate_change_key, @@ -229,20 +230,11 @@ pub fn rewards_accumulator_handle() -> RewardsAccumulator { RewardsAccumulator::open(key) } -/// Get the storage handle to a validator's self rewards products +/// Get the storage handle to a validator's rewards products pub fn validator_rewards_products_handle( validator: &Address, ) -> RewardsProducts { - let key = storage::validator_self_rewards_product_key(validator); - RewardsProducts::open(key) -} - -/// Get the storage handle to the delegator rewards products associated with a -/// particular validator -pub fn delegator_rewards_products_handle( - validator: &Address, -) -> RewardsProducts { - let key = storage::validator_delegation_rewards_product_key(validator); + let key = storage::validator_rewards_product_key(validator); RewardsProducts::open(key) } @@ -1683,17 +1675,19 @@ pub fn unbond_tokens( where S: StorageRead + StorageWrite, { - tracing::debug!( - "Unbonding token amount {} at epoch {}", - amount.to_string_native(), - current_epoch - ); if amount.is_zero() { return Ok(ResultSlashing::default()); } let params = read_pos_params(storage)?; let pipeline_epoch = current_epoch + params.pipeline_len; + let withdrawable_epoch = current_epoch + params.withdrawable_epoch_offset(); + tracing::debug!( + "Unbonding token amount {} at epoch {}, withdrawable at epoch {}", + amount.to_string_native(), + current_epoch, + withdrawable_epoch + ); // Make sure source is not some other validator if let Some(source) = source { @@ -1735,7 +1729,6 @@ where } let unbonds = unbond_handle(source, validator); - let withdrawable_epoch = current_epoch + params.withdrawable_epoch_offset(); let redelegated_bonds = delegator_redelegated_bonds_handle(source).at(validator); @@ -2033,6 +2026,35 @@ where } } + // Tally rewards (only call if this is not the first epoch) + if current_epoch > Epoch::default() { + let mut rewards = token::Amount::zero(); + + let last_claim_epoch = + get_last_reward_claim_epoch(storage, source, validator)? + .unwrap_or_default(); + let rewards_products = validator_rewards_products_handle(validator); + + for (start_epoch, slashed_amount) in &result_slashing.epoch_map { + // Stop collecting rewards at the moment the unbond is initiated + // (right now) + for ep in + Epoch::iter_bounds_inclusive(*start_epoch, current_epoch.prev()) + { + // Consider the last epoch when rewards were claimed + if ep < last_claim_epoch { + continue; + } + let rp = + rewards_products.get(storage, &ep)?.unwrap_or_default(); + rewards += rp * (*slashed_amount); + } + } + + // Update the rewards from the current unbonds first + add_rewards_to_counter(storage, source, validator, rewards)?; + } + Ok(result_slashing) } @@ -2838,6 +2860,7 @@ where "Unbond delta ({start_epoch}..{withdraw_epoch}), amount {}", amount.to_string_native() ); + // Consider only unbonds that are eligible to be withdrawn if withdraw_epoch > current_epoch { tracing::debug!( "Not yet withdrawable until epoch {withdraw_epoch}" @@ -2868,6 +2891,7 @@ where (amount, eager_redelegated_unbonds), ); } + let slashes = find_validator_slashes(storage, validator)?; // `val resultSlashing` @@ -3027,121 +3051,21 @@ pub fn bond_amount( where S: StorageRead, { - // TODO: our method of applying slashes is not correct! This needs review - let params = read_pos_params(storage)?; + // Outer key is the start epoch used to calculate slashes. The inner + // keys are discarded after applying slashes. + let mut amounts: BTreeMap = BTreeMap::default(); - // TODO: apply rewards - let slashes = find_validator_slashes(storage, &bond_id.validator)?; - // dbg!(&slashes); - // let slash_rates = - // slashes - // .iter() - // .fold(BTreeMap::::new(), |mut map, slash| { - // let tot_rate = map.entry(slash.epoch).or_default(); - // *tot_rate = cmp::min(Dec::one(), *tot_rate + slash.rate); - // map - // }); - // dbg!(&slash_rates); - - // Accumulate incoming redelegations slashes from source validator, if any. - // This ensures that if there're slashes on both src validator and dest - // validator, they're combined correctly. - let mut redelegation_slashes = BTreeMap::::new(); - for res in delegator_redelegated_bonds_handle(&bond_id.source) - .at(&bond_id.validator) - .iter(storage)? - { - let ( - NestedSubKey::Data { - key: redelegation_end, - nested_sub_key: - NestedSubKey::Data { - key: src_validator, - nested_sub_key: SubKey::Data(start), - }, - }, - delta, - ) = res?; - - let list_slashes = validator_slashes_handle(&src_validator) - .iter(storage)? - .map(Result::unwrap) - .filter(|slash| { - let slash_processing_epoch = - slash.epoch + params.slash_processing_epoch_offset(); - start <= slash.epoch - && redelegation_end > slash.epoch - && slash_processing_epoch - > redelegation_end - params.pipeline_len - }) - .collect::>(); - - let slashed_delta = apply_list_slashes(¶ms, &list_slashes, delta); - - // let mut slashed_delta = delta; - // let slashes = find_slashes_in_range( - // storage, - // start, - // Some(redelegation_end), - // &src_validator, - // )?; - // for (slash_epoch, rate) in slashes { - // let slash_processing_epoch = - // slash_epoch + params.slash_processing_epoch_offset(); - // // If the slash was processed after redelegation was submitted - // // it has to be slashed now - // if slash_processing_epoch > redelegation_end - - // params.pipeline_len { let slashed = - // slashed_delta.mul_ceil(rate); slashed_delta -= - // slashed; } - // } - *redelegation_slashes.entry(redelegation_end).or_default() += - delta - slashed_delta; - } - // dbg!(&redelegation_slashes); - + // Bonds let bonds = bond_handle(&bond_id.source, &bond_id.validator).get_data_handler(); - let mut total_active = token::Amount::zero(); for next in bonds.iter(storage)? { - let (bond_epoch, delta) = dbg!(next?); - if bond_epoch > epoch { - continue; - } - - let list_slashes = slashes - .iter() - .filter(|slash| bond_epoch <= slash.epoch) - .cloned() - .collect::>(); - - let mut slashed_delta = - apply_list_slashes(¶ms, &list_slashes, delta); - - // Deduct redelegation src validator slash, if any - if let Some(&redelegation_slash) = redelegation_slashes.get(&bond_epoch) - { - slashed_delta -= redelegation_slash; + let (start, delta) = next?; + if start <= epoch { + let amount = amounts.entry(start).or_default(); + *amount += delta; } - - // let list_slashes = slashes - // .iter() - // .map(Result::unwrap) - // .filter(|slash| bond_epoch <= slash.epoch) - // .collect::>(); - - // for (&slash_epoch, &rate) in &slash_rates { - // if slash_epoch < bond_epoch { - // continue; - // } - // // TODO: think about truncation - // let current_slash = slashed_delta.mul_ceil(rate); - // slashed_delta -= current_slash; - // } - total_active += slashed_delta; } - // dbg!(&total_active); // Add unbonds that are still contributing to stake let unbonds = unbond_handle(&bond_id.source, &bond_id.validator); @@ -3153,31 +3077,16 @@ where }, delta, ) = next?; + // This is the first epoch in which the unbond stops contributing to + // voting power let end = withdrawable_epoch - params.withdrawable_epoch_offset() + params.pipeline_len; if start <= epoch && end > epoch { - let list_slashes = slashes - .iter() - .filter(|slash| start <= slash.epoch && end > slash.epoch) - .cloned() - .collect::>(); - - let slashed_delta = - apply_list_slashes(¶ms, &list_slashes, delta); - - // let mut slashed_delta = delta; - // for (&slash_epoch, &rate) in &slash_rates { - // if start <= slash_epoch && end > slash_epoch { - // // TODO: think about truncation - // let current_slash = slashed_delta.mul_ceil(rate); - // slashed_delta -= current_slash; - // } - // } - total_active += slashed_delta; + let amount = amounts.entry(start).or_default(); + *amount += delta; } } - // dbg!(&total_active); if bond_id.validator != bond_id.source { // Add outgoing redelegations that are still contributing to the source @@ -3204,27 +3113,10 @@ where && start <= epoch && end > epoch { - let list_slashes = slashes - .iter() - .filter(|slash| start <= slash.epoch && end > slash.epoch) - .cloned() - .collect::>(); - - let slashed_delta = - apply_list_slashes(¶ms, &list_slashes, delta); - - // let mut slashed_delta = delta; - // for (&slash_epoch, &rate) in &slash_rates { - // if start <= slash_epoch && end > slash_epoch { - // // TODO: think about truncation - // let current_slash = delta.mul_ceil(rate); - // slashed_delta -= current_slash; - // } - // } - total_active += slashed_delta; + let amount = amounts.entry(start).or_default(); + *amount += delta; } } - // dbg!(&total_active); // Add outgoing redelegation unbonds that are still contributing to // the source validator's stake @@ -3239,7 +3131,7 @@ where key: redelegation_epoch, nested_sub_key: NestedSubKey::Data { - key: withdraw_epoch, + key: _withdraw_epoch, nested_sub_key: NestedSubKey::Data { key: src_validator, @@ -3250,39 +3142,114 @@ where }, delta, ) = res?; - let end = withdraw_epoch - params.withdrawable_epoch_offset() - + params.pipeline_len; if src_validator == bond_id.validator // If the unbonded bond was redelegated after this epoch ... && redelegation_epoch > epoch - // ... the start was before or at this epoch ... + // ... the start was before or at this epoch && start <= epoch - // ... and the end after this epoch - && end > epoch { + let amount = amounts.entry(start).or_default(); + *amount += delta; + } + } + } + + if !amounts.is_empty() { + let slashes = find_validator_slashes(storage, &bond_id.validator)?; + + // Apply slashes + for (&start, amount) in amounts.iter_mut() { + let list_slashes = slashes + .iter() + .filter(|slash| { + let processing_epoch = + slash.epoch + params.slash_processing_epoch_offset(); + // Only use slashes that were processed before or at the + // epoch associated with the bond amount. This assumes + // that slashes are applied before inflation. + processing_epoch <= epoch && start <= slash.epoch + }) + .cloned() + .collect::>(); + + *amount = apply_list_slashes(¶ms, &list_slashes, *amount); + } + } + + Ok(amounts.values().cloned().sum()) +} + +/// Get bond amounts within the `claim_start..=claim_end` epoch range for +/// claiming rewards for a given bond ID. Returns a map of bond amounts +/// associated with every epoch within the given epoch range (accumulative) in +/// which an amount contributed to the validator's stake. +/// This function will only consider slashes that were processed before or at +/// the epoch in which we're calculating the bond amount to correspond to the +/// validator stake that was used to calculate reward products (slashes do *not* +/// retrospectively affect the rewards calculated before slash processing). +pub fn bond_amounts_for_rewards( + storage: &S, + bond_id: &BondId, + claim_start: Epoch, + claim_end: Epoch, +) -> storage_api::Result> +where + S: StorageRead, +{ + let params = read_pos_params(storage)?; + // Outer key is every epoch in which the a bond amount contributed to stake + // and the inner key is the start epoch used to calculate slashes. The inner + // keys are discarded after applying slashes. + let mut amounts: BTreeMap> = + BTreeMap::default(); + + // Only need to do bonds since rewwards are accumulated during + // `unbond_tokens` + let bonds = + bond_handle(&bond_id.source, &bond_id.validator).get_data_handler(); + for next in bonds.iter(storage)? { + let (start, delta) = next?; + + for ep in Epoch::iter_bounds_inclusive(claim_start, claim_end) { + // A bond that wasn't unbonded is added to all epochs up to + // `claim_end` + if start <= ep { + let amount = + amounts.entry(ep).or_default().entry(start).or_default(); + *amount += delta; + } + } + } + + if !amounts.is_empty() { + let slashes = find_validator_slashes(storage, &bond_id.validator)?; + + // Apply slashes + for (&ep, amounts) in amounts.iter_mut() { + for (&start, amount) in amounts.iter_mut() { let list_slashes = slashes .iter() - .filter(|slash| start <= slash.epoch && end > slash.epoch) + .filter(|slash| { + let processing_epoch = slash.epoch + + params.slash_processing_epoch_offset(); + // Only use slashes that were processed before or at the + // epoch associated with the bond amount. This assumes + // that slashes are applied before inflation. + processing_epoch <= ep && start <= slash.epoch + }) .cloned() .collect::>(); - let slashed_delta = - apply_list_slashes(¶ms, &list_slashes, delta); - - // let mut slashed_delta = delta; - // for (&slash_epoch, &rate) in &slash_rates { - // if start <= slash_epoch && end > slash_epoch { - // let current_slash = delta.mul_ceil(rate); - // slashed_delta -= current_slash; - // } - // } - total_active += slashed_delta; + *amount = apply_list_slashes(¶ms, &list_slashes, *amount); } } } - // dbg!(&total_active); - Ok(total_active) + Ok(amounts + .into_iter() + // Flatten the inner maps to discard bond start epochs + .map(|(ep, amounts)| (ep, amounts.values().cloned().sum())) + .collect()) } /// Get the genesis consensus validators stake and consensus key for Tendermint, @@ -4004,17 +3971,8 @@ where let consensus_validators = consensus_validator_set_handle().at(&epoch); // Get total stake of the consensus validator set - let mut total_consensus_stake = token::Amount::zero(); - for validator in consensus_validators.iter(storage)? { - let ( - NestedSubKey::Data { - key: amount, - nested_sub_key: _, - }, - _address, - ) = validator?; - total_consensus_stake += amount; - } + let total_consensus_stake = + get_total_consensus_stake(storage, epoch, ¶ms)?; // Get set of signing validator addresses and the combined stake of // these signers @@ -4120,19 +4078,141 @@ where rewards_frac += coeffs.active_val_coeff * (stake_unscaled / consensus_stake_unscaled); - // Update the rewards accumulator - let prev = rewards_accumulator_handle() - .get(storage, &address)? - .unwrap_or_default(); - values.insert(address, prev + rewards_frac); + // To be added to the rewards accumulator + values.insert(address, rewards_frac); } for (address, value) in values.into_iter() { - rewards_accumulator_handle().insert(storage, address, value)?; + // Update the rewards accumulator + rewards_accumulator_handle().update(storage, address, |prev| { + prev.unwrap_or_default() + value + })?; } Ok(()) } +#[derive(Clone, Debug)] +struct Rewards { + product: Dec, + commissions: token::Amount, +} + +/// Update validator and delegators rewards products and mint the inflation +/// tokens into the PoS account. +/// Any left-over inflation tokens from rounding error of the sum of the +/// rewards is given to the governance address. +pub fn update_rewards_products_and_mint_inflation( + storage: &mut S, + params: &PosParams, + last_epoch: Epoch, + num_blocks_in_last_epoch: u64, + inflation: token::Amount, + staking_token: &Address, +) -> storage_api::Result<()> +where + S: StorageRead + StorageWrite, +{ + // Read the rewards accumulator and calculate the new rewards products + // for the previous epoch + // + // TODO: think about changing the reward to Decimal + let mut reward_tokens_remaining = inflation; + let mut new_rewards_products: HashMap = HashMap::new(); + let mut accumulators_sum = Dec::zero(); + for acc in rewards_accumulator_handle().iter(storage)? { + let (validator, value) = acc?; + accumulators_sum += value; + + // Get reward token amount for this validator + let fractional_claim = value / num_blocks_in_last_epoch; + let reward_tokens = fractional_claim * inflation; + + // Get validator stake at the last epoch + let stake = Dec::from(read_validator_stake( + storage, params, &validator, last_epoch, + )?); + + let commission_rate = validator_commission_rate_handle(&validator) + .get(storage, last_epoch, params)? + .expect("Should be able to find validator commission rate"); + + // Calculate the reward product from the whole validator stake and take + // out the commissions. Because we're using the whole stake to work with + // a single product, we're also taking out commission on validator's + // self-bonds, but it is then included in the rewards claimable by the + // validator so they get it back. + let product = + (Dec::one() - commission_rate) * Dec::from(reward_tokens) / stake; + + // Tally the commission tokens earned by the validator. + // TODO: think abt Dec rounding and if `new_product` should be used + // instead of `reward_tokens` + let commissions = commission_rate * reward_tokens; + + new_rewards_products.insert( + validator, + Rewards { + product, + commissions, + }, + ); + + reward_tokens_remaining -= reward_tokens; + } + for ( + validator, + Rewards { + product, + commissions, + }, + ) in new_rewards_products + { + validator_rewards_products_handle(&validator) + .insert(storage, last_epoch, product)?; + // The commissions belong to the validator + add_rewards_to_counter(storage, &validator, &validator, commissions)?; + } + + // Mint tokens to the PoS account for the last epoch's inflation + let pos_reward_tokens = inflation - reward_tokens_remaining; + tracing::info!( + "Minting tokens for PoS rewards distribution into the PoS account. \ + Amount: {}. Total inflation: {}, number of blocks in the last epoch: \ + {num_blocks_in_last_epoch}, reward accumulators sum: \ + {accumulators_sum}.", + pos_reward_tokens.to_string_native(), + inflation.to_string_native(), + ); + token::credit_tokens( + storage, + staking_token, + &address::POS, + pos_reward_tokens, + )?; + + if reward_tokens_remaining > token::Amount::zero() { + tracing::info!( + "Minting tokens remaining from PoS rewards distribution into the \ + Governance account. Amount: {}.", + reward_tokens_remaining.to_string_native() + ); + token::credit_tokens( + storage, + staking_token, + &address::GOV, + reward_tokens_remaining, + )?; + } + + // Clear validator rewards accumulators + storage.delete_prefix( + // The prefix of `rewards_accumulator_handle` + &storage::consensus_validator_rewards_accumulator_key(), + )?; + + Ok(()) +} + /// Calculate the cubic slashing rate using all slashes within a window around /// the given infraction epoch. There is no cap on the rate applied within this /// function. @@ -5423,3 +5503,130 @@ pub mod test_utils { Ok(params) } } + +/// Claim rewards +pub fn claim_reward_tokens( + storage: &mut S, + source: Option<&Address>, + validator: &Address, + current_epoch: Epoch, +) -> storage_api::Result +where + S: StorageRead + StorageWrite, +{ + tracing::debug!("Claiming rewards in epoch {current_epoch}"); + + let rewards_products = validator_rewards_products_handle(validator); + let source = source.cloned().unwrap_or_else(|| validator.clone()); + tracing::debug!("Source {} --> Validator {}", source, validator); + + if current_epoch == Epoch::default() { + // Nothing to claim in the first epoch + return Ok(token::Amount::zero()); + } + + let last_claim_epoch = + get_last_reward_claim_epoch(storage, &source, validator)?; + if let Some(last_epoch) = last_claim_epoch { + if last_epoch == current_epoch { + // Already claimed in this epoch + return Ok(token::Amount::zero()); + } + } + + let mut reward_tokens = token::Amount::zero(); + + // Want to claim from `last_claim_epoch` to `current_epoch.prev()` since + // rewards are computed at the end of an epoch + let (claim_start, claim_end) = ( + last_claim_epoch.unwrap_or_default(), + // Safe because of the check above + current_epoch.prev(), + ); + let bond_amounts = bond_amounts_for_rewards( + storage, + &BondId { + source: source.clone(), + validator: validator.clone(), + }, + claim_start, + claim_end, + )?; + for (ep, bond_amount) in bond_amounts { + debug_assert!(ep >= claim_start); + debug_assert!(ep <= claim_end); + let rp = rewards_products.get(storage, &ep)?.unwrap_or_default(); + let reward = rp * bond_amount; + reward_tokens += reward; + } + + // Add reward tokens tallied during previous withdrawals + reward_tokens += take_rewards_from_counter(storage, &source, validator)?; + + // Update the last claim epoch in storage + write_last_reward_claim_epoch(storage, &source, validator, current_epoch)?; + + // Transfer the bonded tokens from PoS to the source + let staking_token = staking_token_address(storage); + token::transfer(storage, &staking_token, &ADDRESS, &source, reward_tokens)?; + + Ok(reward_tokens) +} + +/// Get the last epoch in which rewards were claimed from storage, if any +pub fn get_last_reward_claim_epoch( + storage: &S, + delegator: &Address, + validator: &Address, +) -> storage_api::Result> +where + S: StorageRead, +{ + let key = last_pos_reward_claim_epoch_key(delegator, validator); + storage.read(&key) +} + +fn write_last_reward_claim_epoch( + storage: &mut S, + delegator: &Address, + validator: &Address, + epoch: Epoch, +) -> storage_api::Result<()> +where + S: StorageRead + StorageWrite, +{ + let key = last_pos_reward_claim_epoch_key(delegator, validator); + storage.write(&key, epoch) +} + +/// Add tokens to a rewards counter. +fn add_rewards_to_counter( + storage: &mut S, + source: &Address, + validator: &Address, + new_rewards: token::Amount, +) -> storage_api::Result<()> +where + S: StorageRead + StorageWrite, +{ + let key = rewards_counter_key(source, validator); + let current_rewards = + storage.read::(&key)?.unwrap_or_default(); + storage.write(&key, current_rewards + new_rewards) +} + +/// Take tokens from a rewards counter. Deletes the record after reading. +fn take_rewards_from_counter( + storage: &mut S, + source: &Address, + validator: &Address, +) -> storage_api::Result +where + S: StorageRead + StorageWrite, +{ + let key = rewards_counter_key(source, validator); + let current_rewards = + storage.read::(&key)?.unwrap_or_default(); + storage.delete(&key)?; + Ok(current_rewards) +} diff --git a/proof_of_stake/src/storage.rs b/proof_of_stake/src/storage.rs index fe7e6c8d7e..3d44109e81 100644 --- a/proof_of_stake/src/storage.rs +++ b/proof_of_stake/src/storage.rs @@ -21,9 +21,7 @@ const VALIDATOR_DELTAS_STORAGE_KEY: &str = "deltas"; const VALIDATOR_COMMISSION_RATE_STORAGE_KEY: &str = "commission_rate"; const VALIDATOR_MAX_COMMISSION_CHANGE_STORAGE_KEY: &str = "max_commission_rate_change"; -const VALIDATOR_SELF_REWARDS_PRODUCT_KEY: &str = "validator_rewards_product"; -const VALIDATOR_DELEGATION_REWARDS_PRODUCT_KEY: &str = - "delegation_rewards_product"; +const VALIDATOR_REWARDS_PRODUCT_KEY: &str = "validator_rewards_product"; const VALIDATOR_LAST_KNOWN_PRODUCT_EPOCH_KEY: &str = "last_known_rewards_product_epoch"; const SLASHES_PREFIX: &str = "slash"; @@ -43,6 +41,8 @@ const CONSENSUS_KEYS: &str = "consensus_keys"; const LAST_BLOCK_PROPOSER_STORAGE_KEY: &str = "last_block_proposer"; const CONSENSUS_VALIDATOR_SET_ACCUMULATOR_STORAGE_KEY: &str = "validator_rewards_accumulator"; +const LAST_REWARD_CLAIM_EPOCH: &str = "last_reward_claim_epoch"; +const REWARDS_COUNTER_KEY: &str = "validator_rewards_commissions"; const VALIDATOR_INCOMING_REDELEGATIONS_KEY: &str = "incoming_redelegations"; const VALIDATOR_OUTGOING_REDELEGATIONS_KEY: &str = "outgoing_redelegations"; const VALIDATOR_TOTAL_REDELEGATED_BONDED_KEY: &str = "total_redelegated_bonded"; @@ -232,15 +232,15 @@ pub fn is_validator_max_commission_rate_change_key( } } -/// Storage key for validator's self rewards products. -pub fn validator_self_rewards_product_key(validator: &Address) -> Key { +/// Storage key for validator's rewards products. +pub fn validator_rewards_product_key(validator: &Address) -> Key { validator_prefix(validator) - .push(&VALIDATOR_SELF_REWARDS_PRODUCT_KEY.to_owned()) + .push(&VALIDATOR_REWARDS_PRODUCT_KEY.to_owned()) .expect("Cannot obtain a storage key") } -/// Is storage key for validator's self rewards products? -pub fn is_validator_self_rewards_product_key(key: &Key) -> Option<&Address> { +/// Is storage key for validator's rewards products? +pub fn is_validator_rewards_product_key(key: &Key) -> Option<&Address> { match &key.segments[..] { [ DbKeySeg::AddressSeg(addr), @@ -249,7 +249,7 @@ pub fn is_validator_self_rewards_product_key(key: &Key) -> Option<&Address> { DbKeySeg::StringSeg(key), ] if addr == &ADDRESS && prefix == VALIDATOR_STORAGE_PREFIX - && key == VALIDATOR_SELF_REWARDS_PRODUCT_KEY => + && key == VALIDATOR_REWARDS_PRODUCT_KEY => { Some(validator) } @@ -257,10 +257,19 @@ pub fn is_validator_self_rewards_product_key(key: &Key) -> Option<&Address> { } } -/// Storage key for validator's delegation rewards products. -pub fn validator_delegation_rewards_product_key(validator: &Address) -> Key { - validator_prefix(validator) - .push(&VALIDATOR_DELEGATION_REWARDS_PRODUCT_KEY.to_owned()) +/// Storage prefix for rewards counter. +pub fn rewards_counter_prefix() -> Key { + Key::from(ADDRESS.to_db_key()) + .push(&REWARDS_COUNTER_KEY.to_owned()) + .expect("Cannot obtain a storage key") +} + +/// Storage key for rewards counter. +pub fn rewards_counter_key(source: &Address, validator: &Address) -> Key { + rewards_counter_prefix() + .push(&source.to_db_key()) + .expect("Cannot obtain a storage key") + .push(&validator.to_db_key()) .expect("Cannot obtain a storage key") } @@ -324,26 +333,6 @@ pub fn delegator_redelegated_unbonds_key(delegator: &Address) -> Key { .expect("Cannot obtain a storage key") } -/// Is storage key for validator's delegation rewards products? -pub fn is_validator_delegation_rewards_product_key( - key: &Key, -) -> Option<&Address> { - match &key.segments[..] { - [ - DbKeySeg::AddressSeg(addr), - DbKeySeg::StringSeg(prefix), - DbKeySeg::AddressSeg(validator), - DbKeySeg::StringSeg(key), - ] if addr == &ADDRESS - && prefix == VALIDATOR_STORAGE_PREFIX - && key == VALIDATOR_DELEGATION_REWARDS_PRODUCT_KEY => - { - Some(validator) - } - _ => None, - } -} - /// Storage key for validator's last known rewards product epoch. pub fn validator_last_known_product_epoch_key(validator: &Address) -> Key { validator_prefix(validator) @@ -722,6 +711,27 @@ pub fn is_consensus_validator_set_accumulator_key(key: &Key) -> bool { && key == CONSENSUS_VALIDATOR_SET_ACCUMULATOR_STORAGE_KEY) } +/// Storage prefix for epoch at which an account last claimed PoS inflationary +/// rewards. +pub fn last_pos_reward_claim_epoch_prefix() -> Key { + Key::from(ADDRESS.to_db_key()) + .push(&LAST_REWARD_CLAIM_EPOCH.to_owned()) + .expect("Cannot obtain a storage key") +} + +/// Storage key for epoch at which an account last claimed PoS inflationary +/// rewards. +pub fn last_pos_reward_claim_epoch_key( + delegator: &Address, + validator: &Address, +) -> Key { + last_pos_reward_claim_epoch_prefix() + .push(&delegator.to_db_key()) + .expect("Cannot obtain a storage key") + .push(&validator.to_db_key()) + .expect("Cannot obtain a storage key") +} + /// Get validator address from bond key pub fn get_validator_address_from_bond(key: &Key) -> Option
{ match key.get_at(3) { diff --git a/proof_of_stake/src/tests.rs b/proof_of_stake/src/tests.rs index 35c1d04fae..cfba73a03e 100644 --- a/proof_of_stake/src/tests.rs +++ b/proof_of_stake/src/tests.rs @@ -5,7 +5,7 @@ mod state_machine_v2; mod utils; use std::cmp::{max, min}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::ops::{Deref, Range}; use std::str::FromStr; @@ -41,12 +41,13 @@ use test_log::test; use crate::epoched::DEFAULT_NUM_PAST_EPOCHS; use crate::parameters::testing::arb_pos_params; use crate::parameters::{OwnedPosParams, PosParams}; +use crate::rewards::PosRewardsCalculator; use crate::test_utils::test_init_genesis; use crate::types::{ into_tm_voting_power, BondDetails, BondId, BondsAndUnbondsDetails, ConsensusValidator, EagerRedelegatedBondsMap, GenesisValidator, Position, RedelegatedTokens, ReverseOrdTokenAmount, Slash, SlashType, UnbondDetails, - ValidatorSetUpdate, ValidatorState, WeightedValidator, + ValidatorSetUpdate, ValidatorState, VoteInfo, WeightedValidator, }; use crate::{ apply_list_slashes, become_validator, below_capacity_validator_set_handle, @@ -228,6 +229,69 @@ proptest! { } } +proptest! { + // Generate arb valid input for `test_unslashed_bond_amount_aux` + #![proptest_config(Config { + cases: 1, + .. Config::default() + })] + #[test] + fn test_unslashed_bond_amount( + + genesis_validators in arb_genesis_validators(4..5, None), + + ) { + test_unslashed_bond_amount_aux(genesis_validators) + } +} + +proptest! { + // Generate arb valid input for `test_slashed_bond_amount_aux` + #![proptest_config(Config { + cases: 1, + .. Config::default() + })] + #[test] + fn test_slashed_bond_amount( + + genesis_validators in arb_genesis_validators(4..5, None), + + ) { + test_slashed_bond_amount_aux(genesis_validators) + } +} + +proptest! { + // Generate arb valid input for `test_log_block_rewards_aux` + #![proptest_config(Config { + cases: 1, + .. Config::default() + })] + #[test] + fn test_log_block_rewards( + genesis_validators in arb_genesis_validators(4..10, None), + params in arb_pos_params(Some(5)) + + ) { + test_log_block_rewards_aux(genesis_validators, params) + } +} + +proptest! { + // Generate arb valid input for `test_update_rewards_products_aux` + #![proptest_config(Config { + cases: 1, + .. Config::default() + })] + #[test] + fn test_update_rewards_products( + genesis_validators in arb_genesis_validators(4..10, None), + + ) { + test_update_rewards_products_aux(genesis_validators) + } +} + fn arb_params_and_genesis_validators( num_max_validator_slots: Option, val_size: Range, @@ -5734,3 +5798,759 @@ fn test_overslashing_aux(mut validators: Vec) { let exp_bond_amount = token::Amount::zero(); assert_eq!(self_bond_amount, exp_bond_amount); } + +fn test_unslashed_bond_amount_aux(validators: Vec) { + let mut storage = TestWlStorage::default(); + let params = OwnedPosParams { + unbonding_len: 4, + ..Default::default() + }; + + // Genesis + let mut current_epoch = storage.storage.block.epoch; + let params = test_init_genesis( + &mut storage, + params, + validators.clone().into_iter(), + current_epoch, + ) + .unwrap(); + storage.commit_block().unwrap(); + + let validator1 = validators[0].address.clone(); + let validator2 = validators[1].address.clone(); + + // Get a delegator with some tokens + let staking_token = staking_token_address(&storage); + let delegator = address::testing::gen_implicit_address(); + let del_balance = token::Amount::from_uint(1_000_000, 0).unwrap(); + credit_tokens(&mut storage, &staking_token, &delegator, del_balance) + .unwrap(); + + // Bond to validator 1 + super::bond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 10_000.into(), + current_epoch, + None, + ) + .unwrap(); + + // Unbond some from validator 1 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 1_342.into(), + current_epoch, + false, + ) + .unwrap(); + + // Redelegate some from validator 1 -> 2 + super::redelegate_tokens( + &mut storage, + &delegator, + &validator1, + &validator2, + current_epoch, + 1_875.into(), + ) + .unwrap(); + + // Unbond some from validator 2 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator2, + 584.into(), + current_epoch, + false, + ) + .unwrap(); + + // Advance an epoch + current_epoch = advance_epoch(&mut storage, ¶ms); + super::process_slashes(&mut storage, current_epoch).unwrap(); + + // Bond to validator 1 + super::bond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 384.into(), + current_epoch, + None, + ) + .unwrap(); + + // Unbond some from validator 1 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 144.into(), + current_epoch, + false, + ) + .unwrap(); + + // Redelegate some from validator 1 -> 2 + super::redelegate_tokens( + &mut storage, + &delegator, + &validator1, + &validator2, + current_epoch, + 3_448.into(), + ) + .unwrap(); + + // Unbond some from validator 2 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator2, + 699.into(), + current_epoch, + false, + ) + .unwrap(); + + // Advance an epoch + current_epoch = advance_epoch(&mut storage, ¶ms); + super::process_slashes(&mut storage, current_epoch).unwrap(); + + // Bond to validator 1 + super::bond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 4_384.into(), + current_epoch, + None, + ) + .unwrap(); + + // Redelegate some from validator 1 -> 2 + super::redelegate_tokens( + &mut storage, + &delegator, + &validator1, + &validator2, + current_epoch, + 1_008.into(), + ) + .unwrap(); + + // Unbond some from validator 2 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator2, + 3_500.into(), + current_epoch, + false, + ) + .unwrap(); + + // Checks + let val1_init_stake = validators[0].tokens; + + for epoch in Epoch::iter_bounds_inclusive( + Epoch(0), + current_epoch + params.pipeline_len, + ) { + let bond_amount = crate::bond_amount( + &storage, + &BondId { + source: delegator.clone(), + validator: validator1.clone(), + }, + epoch, + ) + .unwrap_or_default(); + + let val_stake = + crate::read_validator_stake(&storage, ¶ms, &validator1, epoch) + .unwrap(); + // dbg!(&bond_amount); + assert_eq!(val_stake - val1_init_stake, bond_amount); + } +} + +fn test_log_block_rewards_aux( + validators: Vec, + params: OwnedPosParams, +) { + tracing::info!( + "New case with {} validators: {:#?}", + validators.len(), + validators + .iter() + .map(|v| (&v.address, v.tokens.to_string_native())) + .collect::>() + ); + let mut s = TestWlStorage::default(); + // Init genesis + let current_epoch = s.storage.block.epoch; + let params = test_init_genesis( + &mut s, + params, + validators.clone().into_iter(), + current_epoch, + ) + .unwrap(); + s.commit_block().unwrap(); + let total_stake = + crate::get_total_consensus_stake(&s, current_epoch, ¶ms).unwrap(); + let consensus_set = + crate::read_consensus_validator_set_addresses(&s, current_epoch) + .unwrap(); + let proposer_address = consensus_set.iter().next().unwrap().clone(); + + tracing::info!( + ?params.block_proposer_reward, + ?params.block_vote_reward, + ); + tracing::info!(?proposer_address,); + + // Rewards accumulator should be empty at first + let rewards_handle = crate::rewards_accumulator_handle(); + assert!(rewards_handle.is_empty(&s).unwrap()); + + let mut last_rewards = BTreeMap::default(); + + let num_blocks = 100; + // Loop through `num_blocks`, log rewards & check results + for i in 0..num_blocks { + tracing::info!(""); + tracing::info!("Block {}", i + 1); + + // A helper closure to prepare minimum required votes + let prep_votes = |epoch| { + // Ceil of 2/3 of total stake + let min_required_votes = total_stake.mul_ceil(Dec::two() / 3); + + let mut total_votes = token::Amount::zero(); + let mut non_voters = HashSet::
::default(); + let mut prep_vote = |validator| { + // Add validator vote if it's in consensus set and if we don't + // yet have min required votes + if consensus_set.contains(validator) + && total_votes < min_required_votes + { + let stake = + read_validator_stake(&s, ¶ms, validator, epoch) + .unwrap(); + total_votes += stake; + let validator_vp = + into_tm_voting_power(params.tm_votes_per_token, stake) + as u64; + tracing::info!("Validator {validator} signed"); + Some(VoteInfo { + validator_address: validator.clone(), + validator_vp, + }) + } else { + non_voters.insert(validator.clone()); + None + } + }; + + let votes: Vec = validators + .iter() + .rev() + .filter_map(|validator| prep_vote(&validator.address)) + .collect(); + (votes, total_votes, non_voters) + }; + + let (votes, signing_stake, non_voters) = prep_votes(current_epoch); + crate::log_block_rewards( + &mut s, + current_epoch, + &proposer_address, + votes.clone(), + ) + .unwrap(); + + assert!(!rewards_handle.is_empty(&s).unwrap()); + + let rewards_calculator = PosRewardsCalculator { + proposer_reward: params.block_proposer_reward, + signer_reward: params.block_vote_reward, + signing_stake, + total_stake, + }; + let coeffs = rewards_calculator.get_reward_coeffs().unwrap(); + tracing::info!(?coeffs); + + // Check proposer reward + let stake = + read_validator_stake(&s, ¶ms, &proposer_address, current_epoch) + .unwrap(); + let proposer_signing_reward = votes.iter().find_map(|vote| { + if vote.validator_address == proposer_address { + let signing_fraction = + Dec::from(stake) / Dec::from(signing_stake); + Some(coeffs.signer_coeff * signing_fraction) + } else { + None + } + }); + let expected_proposer_rewards = last_rewards.get(&proposer_address).copied().unwrap_or_default() + + // Proposer reward + coeffs.proposer_coeff + // Consensus validator reward + + (coeffs.active_val_coeff + * (Dec::from(stake) / Dec::from(total_stake))) + // Signing reward (if proposer voted) + + proposer_signing_reward + .unwrap_or_default(); + tracing::info!( + "Expected proposer rewards: {expected_proposer_rewards}. Signed \ + block: {}", + proposer_signing_reward.is_some() + ); + assert_eq!( + rewards_handle.get(&s, &proposer_address).unwrap(), + Some(expected_proposer_rewards) + ); + + // Check voters rewards + for VoteInfo { + validator_address, .. + } in votes.iter() + { + // Skip proposer, in case voted - already checked + if validator_address == &proposer_address { + continue; + } + + let stake = read_validator_stake( + &s, + ¶ms, + validator_address, + current_epoch, + ) + .unwrap(); + let signing_fraction = Dec::from(stake) / Dec::from(signing_stake); + let expected_signer_rewards = last_rewards + .get(validator_address) + .copied() + .unwrap_or_default() + + coeffs.signer_coeff * signing_fraction + + (coeffs.active_val_coeff + * (Dec::from(stake) / Dec::from(total_stake))); + tracing::info!( + "Expected signer {validator_address} rewards: \ + {expected_signer_rewards}" + ); + assert_eq!( + rewards_handle.get(&s, validator_address).unwrap(), + Some(expected_signer_rewards) + ); + } + + // Check non-voters rewards, if any + for address in non_voters { + // Skip proposer, in case it didn't vote - already checked + if address == proposer_address { + continue; + } + + if consensus_set.contains(&address) { + let stake = + read_validator_stake(&s, ¶ms, &address, current_epoch) + .unwrap(); + let expected_non_signer_rewards = + last_rewards.get(&address).copied().unwrap_or_default() + + coeffs.active_val_coeff + * (Dec::from(stake) / Dec::from(total_stake)); + tracing::info!( + "Expected non-signer {address} rewards: \ + {expected_non_signer_rewards}" + ); + assert_eq!( + rewards_handle.get(&s, &address).unwrap(), + Some(expected_non_signer_rewards) + ); + } else { + let last_reward = last_rewards.get(&address).copied(); + assert_eq!( + rewards_handle.get(&s, &address).unwrap(), + last_reward + ); + } + } + s.commit_block().unwrap(); + + last_rewards = + crate::rewards_accumulator_handle().collect_map(&s).unwrap(); + + let rewards_sum: Dec = last_rewards.values().copied().sum(); + let expected_sum = Dec::one() * (i as u64 + 1); + let err_tolerance = Dec::new(1, 9).unwrap(); + let fail_msg = format!( + "Expected rewards sum at block {} to be {expected_sum}, got \ + {rewards_sum}. Error tolerance {err_tolerance}.", + i + 1 + ); + assert!(expected_sum <= rewards_sum + err_tolerance, "{fail_msg}"); + assert!(rewards_sum <= expected_sum, "{fail_msg}"); + } +} + +fn test_update_rewards_products_aux(validators: Vec) { + tracing::info!( + "New case with {} validators: {:#?}", + validators.len(), + validators + .iter() + .map(|v| (&v.address, v.tokens.to_string_native())) + .collect::>() + ); + let mut s = TestWlStorage::default(); + // Init genesis + let current_epoch = s.storage.block.epoch; + let params = OwnedPosParams::default(); + let params = test_init_genesis( + &mut s, + params, + validators.into_iter(), + current_epoch, + ) + .unwrap(); + s.commit_block().unwrap(); + + let staking_token = staking_token_address(&s); + let consensus_set = + crate::read_consensus_validator_set_addresses(&s, current_epoch) + .unwrap(); + + // Start a new epoch + let current_epoch = advance_epoch(&mut s, ¶ms); + + // Read some data before applying rewards + let pos_balance_pre = + read_balance(&s, &staking_token, &address::POS).unwrap(); + let gov_balance_pre = + read_balance(&s, &staking_token, &address::GOV).unwrap(); + + let num_consensus_validators = consensus_set.len() as u64; + let accum_val = Dec::one() / num_consensus_validators; + let num_blocks_in_last_epoch = 1000; + + // Assign some reward accumulator values to consensus validator + for validator in &consensus_set { + crate::rewards_accumulator_handle() + .insert( + &mut s, + validator.clone(), + accum_val * num_blocks_in_last_epoch, + ) + .unwrap(); + } + + // Distribute inflation into rewards + let last_epoch = current_epoch.prev(); + let inflation = token::Amount::native_whole(10_000_000); + crate::update_rewards_products_and_mint_inflation( + &mut s, + ¶ms, + last_epoch, + num_blocks_in_last_epoch, + inflation, + &staking_token, + ) + .unwrap(); + + let pos_balance_post = + read_balance(&s, &staking_token, &address::POS).unwrap(); + let gov_balance_post = + read_balance(&s, &staking_token, &address::GOV).unwrap(); + + assert_eq!( + pos_balance_pre + gov_balance_pre + inflation, + pos_balance_post + gov_balance_post, + "Expected inflation to be minted to PoS and left-over amount to Gov" + ); + + let pos_credit = pos_balance_post - pos_balance_pre; + let gov_credit = gov_balance_post - gov_balance_pre; + assert!( + pos_credit > gov_credit, + "PoS must receive more tokens than Gov, but got {} in PoS and {} in \ + Gov", + pos_credit.to_string_native(), + gov_credit.to_string_native() + ); + + // Rewards accumulator must be cleared out + let rewards_handle = crate::rewards_accumulator_handle(); + assert!(rewards_handle.is_empty(&s).unwrap()); +} + +fn test_slashed_bond_amount_aux(validators: Vec) { + let mut storage = TestWlStorage::default(); + let params = OwnedPosParams { + unbonding_len: 4, + ..Default::default() + }; + + let init_tot_stake = validators + .clone() + .into_iter() + .fold(token::Amount::zero(), |acc, v| acc + v.tokens); + let val1_init_stake = validators[0].tokens; + + let mut validators = validators; + validators[0].tokens = (init_tot_stake - val1_init_stake) / 30; + + // Genesis + let mut current_epoch = storage.storage.block.epoch; + let params = test_init_genesis( + &mut storage, + params, + validators.clone().into_iter(), + current_epoch, + ) + .unwrap(); + storage.commit_block().unwrap(); + + let validator1 = validators[0].address.clone(); + let validator2 = validators[1].address.clone(); + + // Get a delegator with some tokens + let staking_token = staking_token_address(&storage); + let delegator = address::testing::gen_implicit_address(); + let del_balance = token::Amount::from_uint(1_000_000, 0).unwrap(); + credit_tokens(&mut storage, &staking_token, &delegator, del_balance) + .unwrap(); + + // Bond to validator 1 + super::bond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 10_000.into(), + current_epoch, + None, + ) + .unwrap(); + + // Unbond some from validator 1 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 1_342.into(), + current_epoch, + false, + ) + .unwrap(); + + // Redelegate some from validator 1 -> 2 + super::redelegate_tokens( + &mut storage, + &delegator, + &validator1, + &validator2, + current_epoch, + 1_875.into(), + ) + .unwrap(); + + // Unbond some from validator 2 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator2, + 584.into(), + current_epoch, + false, + ) + .unwrap(); + + // Advance an epoch to 1 + current_epoch = advance_epoch(&mut storage, ¶ms); + super::process_slashes(&mut storage, current_epoch).unwrap(); + + // Bond to validator 1 + super::bond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 384.into(), + current_epoch, + None, + ) + .unwrap(); + + // Unbond some from validator 1 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 144.into(), + current_epoch, + false, + ) + .unwrap(); + + // Redelegate some from validator 1 -> 2 + super::redelegate_tokens( + &mut storage, + &delegator, + &validator1, + &validator2, + current_epoch, + 3_448.into(), + ) + .unwrap(); + + // Unbond some from validator 2 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator2, + 699.into(), + current_epoch, + false, + ) + .unwrap(); + + // Advance an epoch to ep 2 + current_epoch = advance_epoch(&mut storage, ¶ms); + super::process_slashes(&mut storage, current_epoch).unwrap(); + + // Bond to validator 1 + super::bond_tokens( + &mut storage, + Some(&delegator), + &validator1, + 4_384.into(), + current_epoch, + None, + ) + .unwrap(); + + // Redelegate some from validator 1 -> 2 + super::redelegate_tokens( + &mut storage, + &delegator, + &validator1, + &validator2, + current_epoch, + 1_008.into(), + ) + .unwrap(); + + // Unbond some from validator 2 + super::unbond_tokens( + &mut storage, + Some(&delegator), + &validator2, + 3_500.into(), + current_epoch, + false, + ) + .unwrap(); + + // Advance two epochs to ep 4 + for _ in 0..2 { + current_epoch = advance_epoch(&mut storage, ¶ms); + super::process_slashes(&mut storage, current_epoch).unwrap(); + } + + // Find some slashes committed in various epochs + super::slash( + &mut storage, + ¶ms, + current_epoch, + Epoch(1), + 1_u64, + SlashType::DuplicateVote, + &validator1, + current_epoch, + ) + .unwrap(); + super::slash( + &mut storage, + ¶ms, + current_epoch, + Epoch(2), + 1_u64, + SlashType::DuplicateVote, + &validator1, + current_epoch, + ) + .unwrap(); + super::slash( + &mut storage, + ¶ms, + current_epoch, + Epoch(2), + 1_u64, + SlashType::DuplicateVote, + &validator1, + current_epoch, + ) + .unwrap(); + super::slash( + &mut storage, + ¶ms, + current_epoch, + Epoch(3), + 1_u64, + SlashType::DuplicateVote, + &validator1, + current_epoch, + ) + .unwrap(); + + // Advance such that these slashes are all processed + for _ in 0..params.slash_processing_epoch_offset() { + current_epoch = advance_epoch(&mut storage, ¶ms); + super::process_slashes(&mut storage, current_epoch).unwrap(); + } + + let pipeline_epoch = current_epoch + params.pipeline_len; + + let del_bond_amount = crate::bond_amount( + &storage, + &BondId { + source: delegator.clone(), + validator: validator1.clone(), + }, + pipeline_epoch, + ) + .unwrap_or_default(); + + let self_bond_amount = crate::bond_amount( + &storage, + &BondId { + source: validator1.clone(), + validator: validator1.clone(), + }, + pipeline_epoch, + ) + .unwrap_or_default(); + + let val_stake = crate::read_validator_stake( + &storage, + ¶ms, + &validator1, + pipeline_epoch, + ) + .unwrap(); + // dbg!(&val_stake); + // dbg!(&del_bond_amount); + // dbg!(&self_bond_amount); + + let diff = val_stake - self_bond_amount - del_bond_amount; + assert!(diff <= 2.into()); +} diff --git a/sdk/src/args.rs b/sdk/src/args.rs index f8454648b5..316411e920 100644 --- a/sdk/src/args.rs +++ b/sdk/src/args.rs @@ -1133,6 +1133,43 @@ impl Withdraw { } } +/// Claim arguments +#[derive(Clone, Debug)] +pub struct ClaimRewards { + /// Common tx arguments + pub tx: Tx, + /// Validator address + pub validator: C::Address, + /// Source address for claiming rewards due to bonds. For self-bonds, the + /// validator is also the source + pub source: Option, + /// Path to the TX WASM code file + pub tx_code_path: PathBuf, +} + +impl TxBuilder for ClaimRewards { + fn tx(self, func: F) -> Self + where + F: FnOnce(Tx) -> Tx, + { + ClaimRewards { + tx: func(self.tx), + ..self + } + } +} + +impl ClaimRewards { + /// Build a transaction from this builder + pub async fn build<'a>( + &self, + context: &impl Namada<'a>, + ) -> crate::error::Result<(crate::proto::Tx, SigningTxData, Option)> + { + tx::build_claim_rewards(context, self).await + } +} + /// Query asset conversions #[derive(Clone, Debug)] pub struct QueryConversions { diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index e53b7cfb69..ae09d3ed6e 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -65,11 +65,11 @@ use crate::signing::SigningTxData; use crate::token::DenominatedAmount; use crate::tx::{ ProcessTxResponse, TX_BOND_WASM, TX_BRIDGE_POOL_WASM, - TX_CHANGE_COMMISSION_WASM, TX_IBC_WASM, TX_INIT_PROPOSAL, - TX_INIT_VALIDATOR_WASM, TX_RESIGN_STEWARD, TX_REVEAL_PK, TX_TRANSFER_WASM, - TX_UNBOND_WASM, TX_UNJAIL_VALIDATOR_WASM, TX_UPDATE_ACCOUNT_WASM, - TX_UPDATE_STEWARD_COMMISSION, TX_VOTE_PROPOSAL, TX_WITHDRAW_WASM, - VP_USER_WASM, + TX_CHANGE_COMMISSION_WASM, TX_CLAIM_REWARDS_WASM, TX_IBC_WASM, + TX_INIT_PROPOSAL, TX_INIT_VALIDATOR_WASM, TX_RESIGN_STEWARD, TX_REVEAL_PK, + TX_TRANSFER_WASM, TX_UNBOND_WASM, TX_UNJAIL_VALIDATOR_WASM, + TX_UPDATE_ACCOUNT_WASM, TX_UPDATE_STEWARD_COMMISSION, TX_VOTE_PROPOSAL, + TX_WITHDRAW_WASM, VP_USER_WASM, }; use crate::wallet::{Wallet, WalletIo, WalletStorage}; @@ -342,6 +342,16 @@ pub trait Namada<'a>: Sized { } } + /// Make a Claim-rewards builder from the given minimum set of arguments + fn new_claim_rewards(&self, validator: Address) -> args::ClaimRewards { + args::ClaimRewards { + validator, + source: None, + tx_code_path: PathBuf::from(TX_CLAIM_REWARDS_WASM), + tx: self.tx_builder(), + } + } + /// Make a Withdraw builder from the given minimum set of arguments fn new_add_erc20_transfer( &self, diff --git a/sdk/src/signing.rs b/sdk/src/signing.rs index 381df34634..3a35b4e772 100644 --- a/sdk/src/signing.rs +++ b/sdk/src/signing.rs @@ -48,10 +48,11 @@ use crate::masp::make_asset_type; use crate::proto::{MaspBuilder, Section, Tx}; use crate::rpc::{query_wasm_code_hash, validate_amount}; use crate::tx::{ - TX_BOND_WASM, TX_CHANGE_COMMISSION_WASM, TX_IBC_WASM, TX_INIT_ACCOUNT_WASM, - TX_INIT_PROPOSAL, TX_INIT_VALIDATOR_WASM, TX_REVEAL_PK, TX_TRANSFER_WASM, - TX_UNBOND_WASM, TX_UNJAIL_VALIDATOR_WASM, TX_UPDATE_ACCOUNT_WASM, - TX_VOTE_PROPOSAL, TX_WITHDRAW_WASM, VP_USER_WASM, + TX_BOND_WASM, TX_CHANGE_COMMISSION_WASM, TX_CLAIM_REWARDS_WASM, + TX_IBC_WASM, TX_INIT_ACCOUNT_WASM, TX_INIT_PROPOSAL, + TX_INIT_VALIDATOR_WASM, TX_REVEAL_PK, TX_TRANSFER_WASM, TX_UNBOND_WASM, + TX_UNJAIL_VALIDATOR_WASM, TX_UPDATE_ACCOUNT_WASM, TX_VOTE_PROPOSAL, + TX_WITHDRAW_WASM, VP_USER_WASM, }; pub use crate::wallet::store::AddressVpType; use crate::wallet::{Wallet, WalletIo}; @@ -916,6 +917,8 @@ pub async fn to_ledger_vector<'a>( let user_hash = query_wasm_code_hash(context, VP_USER_WASM).await?; let unjail_validator_hash = query_wasm_code_hash(context, TX_UNJAIL_VALIDATOR_WASM).await?; + let claim_rewards_hash = + query_wasm_code_hash(context, TX_CLAIM_REWARDS_WASM).await?; // To facilitate lookups of human-readable token names let tokens: HashMap = context @@ -1439,6 +1442,28 @@ pub async fn to_ledger_vector<'a>( } tv.output_expert .push(format!("Validator : {}", withdraw.validator)); + } else if code_hash == claim_rewards_hash { + let claim = pos::Withdraw::try_from_slice( + &tx.data() + .ok_or_else(|| Error::Other("Invalid Data".to_string()))?, + ) + .map_err(|err| { + Error::from(EncodingError::Conversion(err.to_string())) + })?; + + tv.name = "Claim_Rewards_0".to_string(); + + tv.output.push("Type : Claim Rewards".to_string()); + if let Some(source) = claim.source.as_ref() { + tv.output.push(format!("Source : {}", source)); + } + tv.output.push(format!("Validator : {}", claim.validator)); + + if let Some(source) = claim.source.as_ref() { + tv.output_expert.push(format!("Source : {}", source)); + } + tv.output_expert + .push(format!("Validator : {}", claim.validator)); } else if code_hash == change_commission_hash { let commission_change = pos::CommissionChange::try_from_slice( &tx.data() diff --git a/sdk/src/tx.rs b/sdk/src/tx.rs index 2a7919895b..22b1e82b55 100644 --- a/sdk/src/tx.rs +++ b/sdk/src/tx.rs @@ -96,6 +96,8 @@ pub const TX_BOND_WASM: &str = "tx_bond.wasm"; pub const TX_UNBOND_WASM: &str = "tx_unbond.wasm"; /// Withdraw WASM path pub const TX_WITHDRAW_WASM: &str = "tx_withdraw.wasm"; +/// Claim-rewards WASM path +pub const TX_CLAIM_REWARDS_WASM: &str = "tx_claim_rewards.wasm"; /// Bridge pool WASM path pub const TX_BRIDGE_POOL_WASM: &str = "tx_bridge_pool.wasm"; /// Change commission WASM path @@ -989,11 +991,18 @@ pub async fn build_withdraw<'a>( let epoch = rpc::query_epoch(context.client()).await?; + // Check that the validator address is actually a validator let validator = known_validator_or_err(validator.clone(), tx_args.force, context) .await?; - let source = source.clone(); + // Check that the source address exists on chain + let source = match source.clone() { + Some(source) => source_exists_or_err(source, tx_args.force, context) + .await + .map(Some), + None => Ok(source.clone()), + }?; // Check the source's current unbond amount let bond_source = source.clone().unwrap_or_else(|| validator.clone()); @@ -1043,6 +1052,54 @@ pub async fn build_withdraw<'a>( .map(|(tx, epoch)| (tx, signing_data, epoch)) } +/// Submit transaction to withdraw an unbond +pub async fn build_claim_rewards<'a>( + context: &impl Namada<'a>, + args::ClaimRewards { + tx: tx_args, + validator, + source, + tx_code_path, + }: &args::ClaimRewards, +) -> Result<(Tx, SigningTxData, Option)> { + let default_address = source.clone().unwrap_or(validator.clone()); + let default_signer = Some(default_address.clone()); + let signing_data = signing::aux_signing_data( + context, + tx_args, + Some(default_address), + default_signer, + ) + .await?; + + // Check that the validator address is actually a validator + let validator = + known_validator_or_err(validator.clone(), tx_args.force, context) + .await?; + + // Check that the source address exists on chain + let source = match source.clone() { + Some(source) => source_exists_or_err(source, tx_args.force, context) + .await + .map(Some), + None => Ok(source.clone()), + }?; + + let data = pos::ClaimRewards { validator, source }; + + build( + context, + tx_args, + tx_code_path.clone(), + data, + do_nothing, + &signing_data.fee_payer, + None, + ) + .await + .map(|(tx, epoch)| (tx, signing_data, epoch)) +} + /// Submit a transaction to unbond pub async fn build_unbond<'a>( context: &impl Namada<'a>, diff --git a/tests/src/e2e/ledger_tests.rs b/tests/src/e2e/ledger_tests.rs index 809feb334d..89ba98fccc 100644 --- a/tests/src/e2e/ledger_tests.rs +++ b/tests/src/e2e/ledger_tests.rs @@ -1162,7 +1162,13 @@ fn pos_bonds() -> Result<()> { Ok(()) } -/// TODO +/// Test for claiming PoS inflationary rewards +/// +/// 1. Run the ledger node +/// 2. Wait some epochs while inflationary rewards accumulate in the PoS system +/// 3. Submit a claim-rewards tx +/// 4. Query the validator's balance before and after the claim tx to ensure +/// that reward tokens were actually transferred #[test] fn pos_rewards() -> Result<()> { let test = setup::network( @@ -1172,12 +1178,12 @@ fn pos_rewards() -> Result<()> { genesis.parameters.parameters.max_expected_time_per_block = 1; genesis.parameters.pos_params.pipeline_len = 2; genesis.parameters.pos_params.unbonding_len = 4; - setup::set_validators(3, genesis, base_dir, default_port_offset) + setup::set_validators(1, genesis, base_dir, default_port_offset) }, None, )?; - for i in 0..3 { + for i in 0..1 { set_ethereum_bridge_mode( &test, &test.net.chain_id, @@ -1188,158 +1194,101 @@ fn pos_rewards() -> Result<()> { } // 1. Run 3 genesis validator ledger nodes - let bg_validator_0 = + let _bg_validator_0 = start_namada_ledger_node_wait_wasm(&test, Some(0), Some(40))? .background(); - let bg_validator_1 = - start_namada_ledger_node_wait_wasm(&test, Some(1), Some(40))? - .background(); - let bg_validator_2 = - start_namada_ledger_node_wait_wasm(&test, Some(2), Some(40))? - .background(); - let validator_zero_rpc = get_actor_rpc(&test, &Who::Validator(0)); - let validator_one_rpc = get_actor_rpc(&test, &Who::Validator(1)); - - // Submit a delegation from Bertha to validator-0 - let tx_args = vec![ - "bond", - "--validator", - "validator-0", - "--source", - BERTHA, - "--amount", - "10000.0", - "--gas-amount", - "0", - "--gas-token", - NAM, - "--signing-keys", - BERTHA_KEY, - "--ledger-address", - &validator_zero_rpc, - ]; - - let mut client = run!(test, Bin::Client, tx_args, Some(40))?; - client.exp_string("Transaction applied with result:")?; - client.exp_string("Transaction is valid.")?; - client.assert_success(); - - // Check that all validator nodes processed the tx with same result - let validator_0 = bg_validator_0.foreground(); - let validator_1 = bg_validator_1.foreground(); - let validator_2 = bg_validator_2.foreground(); - - // let expected_result = "all VPs accepted transaction"; - // validator_0.exp_string(expected_result)?; - // validator_1.exp_string(expected_result)?; - // validator_2.exp_string(expected_result)?; + let validator_0_rpc = get_actor_rpc(&test, &Who::Validator(0)); - let _bg_validator_0 = validator_0.background(); - let _bg_validator_1 = validator_1.background(); - let _bg_validator_2 = validator_2.background(); - // put money in the validator account from its balance account so that it - // can self-bond + // Put money in the validator account from its balance account so that it + // can pay gas fees let tx_args = vec![ "transfer", "--source", - "validator-1-balance-key", + "validator-0-balance-key", "--target", - "validator-1-validator-key", + "validator-0-validator-key", "--amount", "100.0", "--token", "NAM", "--node", - &validator_one_rpc, + &validator_0_rpc, ]; let mut client = - run_as!(test, Who::Validator(1), Bin::Client, tx_args, Some(40))?; - client.exp_string("Transaction applied with result:")?; + run_as!(test, Who::Validator(0), Bin::Client, tx_args, Some(40))?; client.exp_string("Transaction is valid.")?; client.assert_success(); - // Let validator-1 self-bond - let tx_args = vec![ - "bond", - "--validator", - "validator-1", - "--amount", - "30000.0", - "--gas-amount", - "0", - "--gas-token", + + // Wait some epochs + let epoch = get_epoch(&test, &validator_0_rpc)?; + let wait_epoch = epoch + 4_u64; + + let start = Instant::now(); + let loop_timeout = Duration::new(40, 0); + loop { + if Instant::now().duration_since(start) > loop_timeout { + panic!("Timed out waiting for epoch: {}", wait_epoch); + } + let epoch = epoch_sleep(&test, &validator_0_rpc, 40)?; + if dbg!(epoch) >= wait_epoch { + break; + } + } + + let query_balance_args = vec![ + "balance", + "--owner", + "validator-0", + "--token", NAM, - "--signing-keys", - "validator-1-validator-key", - "--ledger-address", - &validator_one_rpc, + "--node", + &validator_0_rpc, ]; - let mut client = - run_as!(test, Who::Validator(1), Bin::Client, tx_args, Some(40))?; - client.exp_string("Transaction applied with result:")?; - client.exp_string("Transaction is valid.")?; + let mut client = run!(test, Bin::Client, query_balance_args, Some(40))?; + let (_, res) = client.exp_regex(r"nam: [0-9\.]+").unwrap(); + let amount_pre = token::Amount::from_str( + res.split(' ').last().unwrap(), + NATIVE_MAX_DECIMAL_PLACES, + ) + .unwrap(); client.assert_success(); - // put money in the validator account from its balance account so that it - // can self-bond + let tx_args = vec![ - "transfer", - "--source", - "validator-2-balance-key", - "--target", - "validator-2-validator-key", - "--amount", - "100.0", - "--token", - "NAM", + "claim-rewards", + "--validator", + "validator-0", + "--signing-keys", + "validator-0-validator-key", "--node", - &validator_one_rpc, + &validator_0_rpc, ]; let mut client = - run_as!(test, Who::Validator(2), Bin::Client, tx_args, Some(40))?; + run_as!(test, Who::Validator(0), Bin::Client, tx_args, Some(40))?; client.exp_string("Transaction applied with result:")?; client.exp_string("Transaction is valid.")?; client.assert_success(); - // Let validator-2 self-bond - let tx_args = vec![ - "bond", - "--validator", - "validator-2", - "--amount", - "25000.0", - "--gas-amount", - "0", - "--gas-token", + let query_balance_args = vec![ + "balance", + "--owner", + "validator-0", + "--token", NAM, - "--signing-keys", - "validator-2-validator-key", - "--ledger-address", - &validator_zero_rpc, + "--node", + &validator_0_rpc, ]; - let mut client = - run_as!(test, Who::Validator(2), Bin::Client, tx_args, Some(40))?; - client.exp_string("Transaction is valid.")?; + let mut client = run!(test, Bin::Client, query_balance_args, Some(40))?; + let (_, res) = client.exp_regex(r"nam: [0-9\.]+").unwrap(); + let amount_post = token::Amount::from_str( + res.split(' ').last().unwrap(), + NATIVE_MAX_DECIMAL_PLACES, + ) + .unwrap(); client.assert_success(); - // Wait some epochs - let epoch = get_epoch(&test, &validator_zero_rpc)?; - let wait_epoch = epoch + 4_u64; - println!( - "Current epoch: {}, earliest epoch for withdrawal: {}", - epoch, wait_epoch - ); + assert!(amount_post > amount_pre); - let start = Instant::now(); - let loop_timeout = Duration::new(40, 0); - loop { - if Instant::now().duration_since(start) > loop_timeout { - panic!("Timed out waiting for epoch: {}", wait_epoch); - } - let epoch = epoch_sleep(&test, &validator_zero_rpc, 40)?; - if dbg!(epoch) >= wait_epoch { - break; - } - } Ok(()) } diff --git a/tx_prelude/src/proof_of_stake.rs b/tx_prelude/src/proof_of_stake.rs index 2582287af2..a364b9d215 100644 --- a/tx_prelude/src/proof_of_stake.rs +++ b/tx_prelude/src/proof_of_stake.rs @@ -7,8 +7,8 @@ use namada_core::types::{key, token}; pub use namada_proof_of_stake::parameters::PosParams; use namada_proof_of_stake::{ become_validator, bond_tokens, change_validator_commission_rate, - read_pos_params, redelegate_tokens, unbond_tokens, unjail_validator, - withdraw_tokens, BecomeValidator, + claim_reward_tokens, read_pos_params, redelegate_tokens, unbond_tokens, + unjail_validator, withdraw_tokens, BecomeValidator, }; pub use namada_proof_of_stake::{parameters, types, ResultSlashing}; @@ -88,6 +88,16 @@ impl Ctx { ) } + /// Claim available reward tokens + pub fn claim_reward_tokens( + &mut self, + source: Option<&Address>, + validator: &Address, + ) -> EnvResult { + let current_epoch = self.get_block_epoch()?; + claim_reward_tokens(self, source, validator, current_epoch) + } + /// Attempt to initialize a validator account. On success, returns the /// initialized validator account's address. pub fn init_validator( diff --git a/wasm/checksums.json b/wasm/checksums.json index 38a44429e5..ac1bd9a7b5 100644 --- a/wasm/checksums.json +++ b/wasm/checksums.json @@ -1,23 +1,24 @@ { - "tx_bond.wasm": "tx_bond.f371b0615f8931ef71e12ecb11ff361d1b52904d2197b1bcd0b245c4b6dc6b85.wasm", - "tx_bridge_pool.wasm": "tx_bridge_pool.fd90aa41331ba118a8ad4c8a6eaff633620f9c13f7f0689d5193650ed0fe5441.wasm", - "tx_change_validator_commission.wasm": "tx_change_validator_commission.f6905933556182a5a8e63a26687c5b7dc08a597caa4d95243362dc9bc9872200.wasm", - "tx_ibc.wasm": "tx_ibc.71fc5960466ebfb2e2304fd2da36206f37ffee463309e80490de0efd05a9774e.wasm", - "tx_init_account.wasm": "tx_init_account.ddaf039045f7a438781afa6359a984d967c31919d0ed624026b7c807359f4ed1.wasm", - "tx_init_proposal.wasm": "tx_init_proposal.ae97d0ce0a99d41e977d0df5e8f1b7840d1b55c56925a33c340e809b332008f7.wasm", - "tx_init_validator.wasm": "tx_init_validator.0d80054aec7009c96b86e2760c17f96acbec2916d4625826b968875fc9080992.wasm", - "tx_redelegate.wasm": "tx_redelegate.201a6d7fe5ba0c8a549398979d96547bb4e2ab2c590346814512e6c6dfbd4b33.wasm", - "tx_resign_steward.wasm": "tx_resign_steward.de1e20e7c59bf3ca16a6eeaa38d3e9d84d0b767ad67693a968982ce5bf29c580.wasm", - "tx_reveal_pk.wasm": "tx_reveal_pk.f54ef16721912c58c9b82729c384857d5e9a755679e54d36d051fed9516e3986.wasm", - "tx_transfer.wasm": "tx_transfer.dbb8e3391339072c6947036b54ddbda386405ed17fdd550ce0e223479d8dfe33.wasm", - "tx_unbond.wasm": "tx_unbond.5fbc7162efa1361257bdb56d7df327013cb918ab8a0ebd49cdebe948a9bb8a0f.wasm", - "tx_unjail_validator.wasm": "tx_unjail_validator.948a6c50d04ad9a0da1a4936d49380f0ba457225110788abc9443761a4fb8ec0.wasm", - "tx_update_account.wasm": "tx_update_account.1bc336c242913481719c6ba756e5403f9f569b97d05cf3a6b117573dc73f4e45.wasm", - "tx_update_steward_commission.wasm": "tx_update_steward_commission.9cadf5b48ad25f6371bbc0ac3e3e010664ce4b9b9104f916f4b87ad2e81eff29.wasm", - "tx_vote_proposal.wasm": "tx_vote_proposal.a8ae56dc352635a01d7e6e953c188c03309810d2e84983516a30832f34a12533.wasm", - "tx_withdraw.wasm": "tx_withdraw.690ad6b17768e24f8523dd5e09ed458fce4b2a3b85710f81731306fac1f28ac7.wasm", - "vp_implicit.wasm": "vp_implicit.05fa0994305859d2e76950744982bb245be36a58d6e6413e6f7eb4fb1d59bce3.wasm", - "vp_masp.wasm": "vp_masp.adbb83cced017ca4f06a5eec4764f81ab30b56d73a179303aa12aa2c03b26964.wasm", - "vp_user.wasm": "vp_user.f50b392a7bc587a126f1377d287ec159cf2859ad2f737c189a97964b3fab7bc9.wasm", - "vp_validator.wasm": "vp_validator.d1fd61cca046ce85e1fd63cc85245e19c7b06c23f5705eb4949f855a8b3ed1a4.wasm" + "tx_bond.wasm": "tx_bond.d4ba6e6b13d8fce4c0a63c77cfb4667320c2c8ab2c5df3e1e4442117ecb068fd.wasm", + "tx_bridge_pool.wasm": "tx_bridge_pool.49191a820e999062a1edc53946e82fafdfc53810a749fdf5036a0d49a7b47e54.wasm", + "tx_change_validator_commission.wasm": "tx_change_validator_commission.a5ed6638798be37d66aec42781353dfcb73d2f96f111e0ebc492fa12d93c6960.wasm", + "tx_claim_rewards.wasm": "tx_claim_rewards.1a165fcb82a58712074e9880e2b1ae8904e0af2d5aca4338c4c6367dc719b9de.wasm", + "tx_ibc.wasm": "tx_ibc.03910c35c6121ef50cd19c0274d810872aa5c3633d8fd4690755afdc9bbcc588.wasm", + "tx_init_account.wasm": "tx_init_account.995dee1d348c06feccaad75a13fdb1d5617bff51e254fc863c97f392612e21f4.wasm", + "tx_init_proposal.wasm": "tx_init_proposal.aafd8623c244bbb59a2fb3d492ed563bcd283c74f6724c7740d081e817765e0e.wasm", + "tx_init_validator.wasm": "tx_init_validator.3cd30f433285eeab9422cd5489cdc5d4a5050195b147a001c18adabac2dea8e5.wasm", + "tx_redelegate.wasm": "tx_redelegate.1455126a387bc78499191e083f42252513ad9c6da37f7a3974f825a23c4d463c.wasm", + "tx_resign_steward.wasm": "tx_resign_steward.7c75577a395f447c60a741fe6aba8c9170a9fcfaf69511c92176043dcd29b318.wasm", + "tx_reveal_pk.wasm": "tx_reveal_pk.48aeb31bd4aa58b2a9caa9166f47253d3776a92af4a7e2922ec5dba8183cec79.wasm", + "tx_transfer.wasm": "tx_transfer.0c5921a221047f3c754ae3aa8303c70accbb9ea1ba5fa555f275962e0832489d.wasm", + "tx_unbond.wasm": "tx_unbond.25b38f5a6e35004169f639a019a1afe9bf8700dd1f36d5e40f5d258e17df649e.wasm", + "tx_unjail_validator.wasm": "tx_unjail_validator.2b449290f430c80cf6397fe9f2fc67ef4c2b7e561299a29c29f8db3dcf9ddb50.wasm", + "tx_update_account.wasm": "tx_update_account.dc3945fe50cc6c16f956e32157fd502c71d5a6336f31210d2b3dcae217217615.wasm", + "tx_update_steward_commission.wasm": "tx_update_steward_commission.c501966ce644ba5c3b5cbc04671cf9233225e942a653fee378cbe42d301ddd39.wasm", + "tx_vote_proposal.wasm": "tx_vote_proposal.b9586804379fe72422f0ccf395ed1b5726f9789d1350811d9bc680cbadee3c81.wasm", + "tx_withdraw.wasm": "tx_withdraw.869d0e5ffd8f32cf6d6151b895497c07e635add2577cd4a85057de2273d17d77.wasm", + "vp_implicit.wasm": "vp_implicit.2034b48046e2640b1d740cfa34d60abe8f6bd30ef63327b3e62995c8ff41e347.wasm", + "vp_masp.wasm": "vp_masp.cca72179bc7d29921f9b9148169a430b4a5bee7522c20e14a8deb312bd6e587c.wasm", + "vp_user.wasm": "vp_user.099e01c8019acdd97df73aa5567024926ec65399923c17b6ed1ff03c5a02d731.wasm", + "vp_validator.wasm": "vp_validator.e02707046f5964e026f1ea953d5da39bdf90116f07c6d4a5cfeefba0c7b27b66.wasm" } \ No newline at end of file diff --git a/wasm/wasm_source/Cargo.toml b/wasm/wasm_source/Cargo.toml index 87e22c96ab..7a6da93af5 100644 --- a/wasm/wasm_source/Cargo.toml +++ b/wasm/wasm_source/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib"] tx_bond = ["namada_tx_prelude"] tx_bridge_pool = ["namada_tx_prelude"] tx_change_validator_commission = ["namada_tx_prelude"] +tx_claim_rewards = ["namada_tx_prelude"] tx_from_intent = ["namada_tx_prelude"] tx_ibc = ["namada_tx_prelude"] tx_init_account = ["namada_tx_prelude"] diff --git a/wasm/wasm_source/Makefile b/wasm/wasm_source/Makefile index e78237c89d..646772246c 100644 --- a/wasm/wasm_source/Makefile +++ b/wasm/wasm_source/Makefile @@ -8,6 +8,7 @@ nightly := $(shell cat ../../rust-nightly-version) wasms := tx_bond wasms += tx_bridge_pool wasms += tx_change_validator_commission +wasms += tx_claim_rewards wasms += tx_ibc wasms += tx_init_account wasms += tx_init_proposal diff --git a/wasm/wasm_source/src/lib.rs b/wasm/wasm_source/src/lib.rs index 139835fe9f..27294890b3 100644 --- a/wasm/wasm_source/src/lib.rs +++ b/wasm/wasm_source/src/lib.rs @@ -4,6 +4,8 @@ pub mod tx_bond; pub mod tx_bridge_pool; #[cfg(feature = "tx_change_validator_commission")] pub mod tx_change_validator_commission; +#[cfg(feature = "tx_claim_rewards")] +pub mod tx_claim_rewards; #[cfg(feature = "tx_ibc")] pub mod tx_ibc; #[cfg(feature = "tx_init_account")] diff --git a/wasm/wasm_source/src/tx_claim_rewards.rs b/wasm/wasm_source/src/tx_claim_rewards.rs new file mode 100644 index 0000000000..62207804af --- /dev/null +++ b/wasm/wasm_source/src/tx_claim_rewards.rs @@ -0,0 +1,15 @@ +//! A tx for a user to claim PoS inflationary rewards due to bonds used as +//! voting power in consensus. + +use namada_tx_prelude::*; + +#[transaction(gas = 260000)] // TODO: needs to be benchmarked +fn apply_tx(ctx: &mut Ctx, tx_data: Tx) -> TxResult { + let signed = tx_data; + let data = signed.data().ok_or_err_msg("Missing data")?; + let withdraw = transaction::pos::Withdraw::try_from_slice(&data[..]) + .wrap_err("failed to decode Withdraw")?; + + ctx.claim_reward_tokens(withdraw.source.as_ref(), &withdraw.validator)?; + Ok(()) +}