diff --git a/.changelog/unreleased/improvements/3816-estimate-staking-reward-rate.md b/.changelog/unreleased/improvements/3816-estimate-staking-reward-rate.md new file mode 100644 index 0000000000..40e2d3ac0c --- /dev/null +++ b/.changelog/unreleased/improvements/3816-estimate-staking-reward-rate.md @@ -0,0 +1,2 @@ +- Adds an SDK and CLI tool to estimate the latest annual staking rewards rate. + ([\#3816](https://github.com/anoma/namada/pull/3816)) \ No newline at end of file diff --git a/crates/apps_lib/src/cli.rs b/crates/apps_lib/src/cli.rs index 8e10945253..894a4b6dd5 100644 --- a/crates/apps_lib/src/cli.rs +++ b/crates/apps_lib/src/cli.rs @@ -298,6 +298,7 @@ pub mod cmds { .subcommand(QueryMetaData::def().display_order(5)) .subcommand(QueryTotalSupply::def().display_order(5)) .subcommand(QueryEffNativeSupply::def().display_order(5)) + .subcommand(QueryStakingRewardsRate::def().display_order(5)) // Actions .subcommand(SignTx::def().display_order(6)) .subcommand(ShieldedSync::def().display_order(6)) @@ -373,6 +374,8 @@ pub mod cmds { Self::parse_with_ctx(matches, QueryTotalSupply); let query_native_supply = Self::parse_with_ctx(matches, QueryEffNativeSupply); + let query_staking_rewards_rate = + Self::parse_with_ctx(matches, QueryStakingRewardsRate); let query_find_validator = Self::parse_with_ctx(matches, QueryFindValidator); let query_result = Self::parse_with_ctx(matches, QueryResult); @@ -449,6 +452,7 @@ pub mod cmds { .or(query_metadata) .or(query_total_supply) .or(query_native_supply) + .or(query_staking_rewards_rate) .or(query_account) .or(sign_tx) .or(shielded_sync) @@ -534,6 +538,7 @@ pub mod cmds { QueryDelegations(QueryDelegations), QueryTotalSupply(QueryTotalSupply), QueryEffNativeSupply(QueryEffNativeSupply), + QueryStakingRewardsRate(QueryStakingRewardsRate), QueryFindValidator(QueryFindValidator), QueryRawBytes(QueryRawBytes), QueryProposal(QueryProposal), @@ -2118,6 +2123,36 @@ pub mod cmds { } } + #[derive(Clone, Debug)] + pub struct QueryStakingRewardsRate( + pub args::QueryStakingRewardsRate, + ); + + impl SubCmd for QueryStakingRewardsRate { + const CMD: &'static str = "staking-rewards-rate"; + + fn parse(matches: &ArgMatches) -> Option + where + Self: Sized, + { + matches.subcommand_matches(Self::CMD).map(|matches| { + QueryStakingRewardsRate(args::QueryStakingRewardsRate::parse( + matches, + )) + }) + } + + fn def() -> App { + App::new(Self::CMD) + .about(wrap!( + "Query the latest estimate of the staking rewards rate \ + based on the most recent minted inflation amount at the \ + last epoch change." + )) + .add_args::>() + } + } + #[derive(Clone, Debug)] pub struct QueryFindValidator(pub args::QueryFindValidator); @@ -7157,6 +7192,32 @@ pub mod args { } } + impl Args for QueryStakingRewardsRate { + fn parse(matches: &ArgMatches) -> Self { + let query = Query::parse(matches); + Self { query } + } + + fn def(app: App) -> App { + app.add_args::>() + } + } + + impl CliToSdk> + for QueryStakingRewardsRate + { + type Error = std::convert::Infallible; + + fn to_sdk( + self, + ctx: &mut Context, + ) -> Result, Self::Error> { + Ok(QueryStakingRewardsRate:: { + query: self.query.to_sdk(ctx)?, + }) + } + } + impl Args for QueryFindValidator { fn parse(matches: &ArgMatches) -> Self { let query = Query::parse(matches); diff --git a/crates/apps_lib/src/cli/client.rs b/crates/apps_lib/src/cli/client.rs index 01a50d3860..a11f6b785b 100644 --- a/crates/apps_lib/src/cli/client.rs +++ b/crates/apps_lib/src/cli/client.rs @@ -661,6 +661,19 @@ impl CliApi { let namada = ctx.to_sdk(client, io); rpc::query_effective_native_supply(&namada).await; } + Sub::QueryStakingRewardsRate(QueryStakingRewardsRate( + args, + )) => { + let chain_ctx = ctx.borrow_mut_chain_or_exit(); + let ledger_address = + chain_ctx.get(&args.query.ledger_address); + let client = client.unwrap_or_else(|| { + C::from_tendermint_address(&ledger_address) + }); + client.wait_until_node_is_synced(&io).await?; + let namada = ctx.to_sdk(client, io); + rpc::query_staking_rewards_rate(&namada).await; + } Sub::QueryFindValidator(QueryFindValidator(args)) => { let chain_ctx = ctx.borrow_mut_chain_or_exit(); let ledger_address = diff --git a/crates/apps_lib/src/client/rpc.rs b/crates/apps_lib/src/client/rpc.rs index 5f0cb7f48c..bb44f82881 100644 --- a/crates/apps_lib/src/client/rpc.rs +++ b/crates/apps_lib/src/client/rpc.rs @@ -15,6 +15,7 @@ use namada_sdk::address::{Address, InternalAddress, MASP}; use namada_sdk::chain::{BlockHeight, Epoch}; use namada_sdk::collections::{HashMap, HashSet}; use namada_sdk::control_flow::time::{Duration, Instant}; +use namada_sdk::dec::Dec; use namada_sdk::events::Event; use namada_sdk::governance::parameters::GovernanceParameters; use namada_sdk::governance::pgf::parameters::PgfParameters; @@ -1364,6 +1365,21 @@ pub async fn query_effective_native_supply(context: &N) { display_line!(context.io(), "nam: {}", native_supply.to_string_native()); } +/// Query the staking rewards rate estimate +pub async fn query_staking_rewards_rate(context: &N) { + let rewards_rate = unwrap_client_response::( + RPC.vp() + .token() + .staking_rewards_rate(context.client()) + .await, + ); + display_line!( + context.io(), + "Current annual staking rewards rate: {}", + rewards_rate + ); +} + /// Query a validator's state information pub async fn query_and_print_validator_state( context: &impl Namada, diff --git a/crates/proof_of_stake/src/rewards.rs b/crates/proof_of_stake/src/rewards.rs index 1efb6040a4..00a77f27c3 100644 --- a/crates/proof_of_stake/src/rewards.rs +++ b/crates/proof_of_stake/src/rewards.rs @@ -656,11 +656,52 @@ where Ok(storage.read::(&key)?.unwrap_or_default()) } +/// Compute an estimation of the most recent staking rewards rate. +pub fn estimate_staking_reward_rate( + storage: &S, +) -> Result +where + S: StorageRead, + Parameters: parameters::Read, + Token: trans_token::Read + trans_token::Write, +{ + // Get needed data in desired form + let total_native_tokens = + Token::get_effective_total_native_supply(storage)?; + let last_staked_ratio = read_last_staked_ratio(storage)? + .expect("Last staked ratio should exist in PoS storage"); + let last_inflation_amount = read_last_pos_inflation_amount(storage)? + .expect("Last inflation amount should exist in PoS storage"); + let epochs_per_year: u64 = Parameters::epochs_per_year(storage)?; + + let total_native_tokens = + Dec::try_from(total_native_tokens).into_storage_result()?; + let last_inflation_amount = + Dec::try_from(last_inflation_amount).into_storage_result()?; + + // Estimate annual inflation rate + let est_inflation_rate = checked!( + last_inflation_amount * epochs_per_year / total_native_tokens + )?; + + // Estimate annual staking rewards rate + let est_staking_reward_rate = + checked!(est_inflation_rate / last_staked_ratio)?; + + Ok(est_staking_reward_rate) +} + #[cfg(test)] mod tests { use std::str::FromStr; + use namada_parameters::storage::get_epochs_per_year_key; + use namada_state::testing::TestState; + use namada_trans_token::storage_key::minted_balance_key; + use storage::write_pos_params; + use super::*; + use crate::OwnedPosParams; #[test] fn test_inflation_calc_up() { @@ -842,10 +883,19 @@ mod tests { #[test] fn test_pos_inflation_playground() { + let mut storage = TestState::default(); + let gov_params = + namada_governance::parameters::GovernanceParameters::default(); + gov_params.init_storage(&mut storage).unwrap(); + write_pos_params(&mut storage, &OwnedPosParams::default()).unwrap(); + let epochs_per_year = 365_u64; + let epy_key = get_epochs_per_year_key(); + storage.write(&epy_key, epochs_per_year).unwrap(); let init_locked_ratio = Dec::from_str("0.1").unwrap(); let mut last_locked_ratio = init_locked_ratio; + let total_native_tokens = 1_000_000_000_u64; let locked_amount = u64::try_from( (init_locked_ratio * total_native_tokens).to_uint().unwrap(), @@ -856,6 +906,13 @@ mod tests { let mut total_native_tokens = token::Amount::native_whole(total_native_tokens); + update_state_for_pos_playground( + &mut storage, + last_locked_ratio, + last_inflation_amount, + total_native_tokens, + ); + let max_reward_rate = Dec::from_str("0.1").unwrap(); let target_ratio = Dec::from_str("0.66666666").unwrap(); let p_gain_nom = Dec::from_str("0.25").unwrap(); @@ -882,17 +939,42 @@ mod tests { let locked_ratio = Dec::try_from(locked_amount).unwrap() / Dec::try_from(total_native_tokens).unwrap(); - let rate = Dec::try_from(inflation).unwrap() + let inflation_rate = Dec::try_from(inflation).unwrap() * Dec::from(epochs_per_year) / Dec::try_from(total_native_tokens).unwrap(); + let staking_rate = inflation_rate / locked_ratio; + println!( "Round {round}: Locked ratio: {locked_ratio}, inflation rate: \ - {rate}", + {inflation_rate}, staking rate: {staking_rate}", ); last_inflation_amount = inflation; total_native_tokens += inflation; last_locked_ratio = locked_ratio; + update_state_for_pos_playground( + &mut storage, + last_locked_ratio, + last_inflation_amount, + total_native_tokens, + ); + + let query_staking_rate = estimate_staking_reward_rate::< + _, + namada_trans_token::Store<_>, + namada_parameters::Store<_>, + >(&storage) + .unwrap(); + // println!(" ----> Query staking rate: {query_staking_rate}"); + if !staking_rate.is_zero() && !query_staking_rate.is_zero() { + let ratio = staking_rate / query_staking_rate; + let residual = ratio.abs_diff(Dec::one()).unwrap(); + assert!(residual < Dec::from_str("0.001").unwrap()); + // println!( + // " ----> Ratio: {}\n", + // staking_rate / query_staking_rate + // ); + } // if rate.abs_diff(&controller.max_reward_rate) // < Dec::from_str("0.01").unwrap() @@ -930,4 +1012,22 @@ mod tests { // ); } } + + fn update_state_for_pos_playground( + storage: &mut S, + last_staked_ratio: Dec, + last_inflation_amount: token::Amount, + total_native_amount: token::Amount, + ) where + S: StorageRead + StorageWrite, + { + write_last_staked_ratio(storage, last_staked_ratio).unwrap(); + write_last_pos_inflation_amount(storage, last_inflation_amount) + .unwrap(); + let total_native_tokens_key = + minted_balance_key(&storage.get_native_token().unwrap()); + storage + .write(&total_native_tokens_key, total_native_amount) + .unwrap(); + } } diff --git a/crates/sdk/src/args.rs b/crates/sdk/src/args.rs index cf063f5d8e..f7349c40ea 100644 --- a/crates/sdk/src/args.rs +++ b/crates/sdk/src/args.rs @@ -2245,6 +2245,13 @@ pub struct QueryEffNativeSupply { pub query: Query, } +/// Query estimate of staking rewards rate +#[derive(Clone, Debug)] +pub struct QueryStakingRewardsRate { + /// Common query args + pub query: Query, +} + /// Query PoS to find a validator #[derive(Clone, Debug)] pub struct QueryFindValidator { diff --git a/crates/sdk/src/queries/vp/token.rs b/crates/sdk/src/queries/vp/token.rs index b0f151eaa1..caea8392f8 100644 --- a/crates/sdk/src/queries/vp/token.rs +++ b/crates/sdk/src/queries/vp/token.rs @@ -2,9 +2,10 @@ use namada_core::address::Address; use namada_core::token; +use namada_proof_of_stake::rewards::estimate_staking_reward_rate; use namada_state::{DBIter, StorageHasher, DB}; use namada_token::{ - get_effective_total_native_supply, read_denom, read_total_supply, + get_effective_total_native_supply, read_denom, read_total_supply, Dec, }; use crate::queries::RequestCtx; @@ -13,6 +14,7 @@ router! {TOKEN, ( "denomination" / [token: Address] ) -> Option = denomination, ( "total_supply" / [token: Address] ) -> token::Amount = total_supply, ( "effective_native_supply" ) -> token::Amount = effective_native_supply, + ( "staking_rewards_rate" ) -> Dec = staking_rewards_rate, } /// Get the number of decimal places (in base 10) for a @@ -51,6 +53,21 @@ where get_effective_total_native_supply(ctx.state) } +/// Get the effective total supply of the native token +fn staking_rewards_rate( + ctx: RequestCtx<'_, D, H, V, T>, +) -> namada_storage::Result +where + D: 'static + DB + for<'iter> DBIter<'iter> + Sync, + H: 'static + StorageHasher + Sync, +{ + estimate_staking_reward_rate::< + _, + crate::token::Store<_>, + crate::parameters::Store<_>, + >(ctx.state) +} + pub mod client_only_methods { use borsh::BorshDeserialize; use namada_core::address::Address; diff --git a/crates/sdk/src/rpc.rs b/crates/sdk/src/rpc.rs index 781ecc41fa..e1e0fd7589 100644 --- a/crates/sdk/src/rpc.rs +++ b/crates/sdk/src/rpc.rs @@ -46,6 +46,7 @@ use namada_proof_of_stake::types::{ }; use namada_state::LastBlock; use namada_token::masp::MaspTokenRewardData; +use namada_token::Dec; use namada_tx::data::{BatchedTxResult, DryRunResult, ResultCode, TxResult}; use namada_tx::event::{Batch as BatchAttr, Code as CodeAttr}; use serde::Serialize; @@ -237,6 +238,15 @@ pub async fn get_effective_native_supply( ) } +/// Query the effective total supply of the native token +pub async fn get_staking_rewards_rate( + client: &C, +) -> Result { + convert_response::( + RPC.vp().token().staking_rewards_rate(client).await, + ) +} + /// Check if the given address is a known validator. pub async fn is_validator( client: &C, diff --git a/crates/tests/src/integration/ledger_tests.rs b/crates/tests/src/integration/ledger_tests.rs index ed34692bb7..7d6322d448 100644 --- a/crates/tests/src/integration/ledger_tests.rs +++ b/crates/tests/src/integration/ledger_tests.rs @@ -544,6 +544,16 @@ fn pos_rewards() -> Result<()> { .unwrap(); assert!(amount_post > amount_pre); + let query_staking_rewards_rate = + vec!["staking-rewards-rate", "--node", &validator_one_rpc]; + let captured = CapturedOutput::of(|| { + run(&node, Bin::Client, query_staking_rewards_rate) + }); + assert_matches!(captured.result, Ok(_)); + let _res = captured + .matches(r"Current annual staking rewards rate: 63.483") + .expect("Test failed"); + Ok(()) }