diff --git a/.changelog/unreleased/features/3974-masp-rewards-estimation.md b/.changelog/unreleased/features/3974-masp-rewards-estimation.md new file mode 100644 index 0000000000..f40c90dd7e --- /dev/null +++ b/.changelog/unreleased/features/3974-masp-rewards-estimation.md @@ -0,0 +1,3 @@ +- Adds a cli command to estimate the amount of MASP rewards that will be accumulated by the next epoch. + This is done by applying the latest set of conversions for each asset again. + ([\#3974](https://github.com/anoma/namada/pull/3974)) \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7b3ff89039..fd79218da1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5401,6 +5401,7 @@ dependencies = [ "sha2 0.9.9", "smooth-operator", "tempfile", + "tendermint-rpc", "test-log", "thiserror", "tokio", diff --git a/crates/apps_lib/src/cli.rs b/crates/apps_lib/src/cli.rs index 8fb3e293b3..1df8c79cd5 100644 --- a/crates/apps_lib/src/cli.rs +++ b/crates/apps_lib/src/cli.rs @@ -273,6 +273,7 @@ pub mod cmds { .subcommand(QueryMaspRewardTokens::def().display_order(5)) .subcommand(QueryBlock::def().display_order(5)) .subcommand(QueryBalance::def().display_order(5)) + .subcommand(QueryRewardsEstimate::def().display_order(5)) .subcommand(QueryBonds::def().display_order(5)) .subcommand(QueryBondedStake::def().display_order(5)) .subcommand(QuerySlashes::def().display_order(5)) @@ -356,6 +357,8 @@ pub mod cmds { Self::parse_with_ctx(matches, QueryMaspRewardTokens); let query_block = Self::parse_with_ctx(matches, QueryBlock); let query_balance = Self::parse_with_ctx(matches, QueryBalance); + let query_rewards_estimate = + Self::parse_with_ctx(matches, QueryRewardsEstimate); let query_bonds = Self::parse_with_ctx(matches, QueryBonds); let query_bonded_stake = Self::parse_with_ctx(matches, QueryBondedStake); @@ -427,6 +430,7 @@ pub mod cmds { .or(query_masp_reward_tokens) .or(query_block) .or(query_balance) + .or(query_rewards_estimate) .or(query_bonds) .or(query_bonded_stake) .or(query_slashes) @@ -523,6 +527,7 @@ pub mod cmds { QueryMaspRewardTokens(QueryMaspRewardTokens), QueryBlock(QueryBlock), QueryBalance(QueryBalance), + QueryRewardsEstimate(QueryRewardsEstimate), QueryBonds(QueryBonds), QueryBondedStake(QueryBondedStake), QueryCommissionRate(QueryCommissionRate), @@ -1875,6 +1880,31 @@ pub mod cmds { } } + #[derive(Clone, Debug)] + pub struct QueryRewardsEstimate( + pub args::QueryRewardsEstimate, + ); + + impl SubCmd for QueryRewardsEstimate { + const CMD: &'static str = "estimate-rewards"; + + fn parse(matches: &ArgMatches) -> Option { + matches.subcommand_matches(Self::CMD).map(|matches| { + QueryRewardsEstimate(args::QueryRewardsEstimate::parse(matches)) + }) + } + + fn def() -> App { + App::new(Self::CMD) + .about(wrap!( + "Estimate the amount of MASP rewards accumulated by the \ + next MASP epoch. Please run shielded-sync first for best \ + results." + )) + .add_args::>() + } + } + #[derive(Clone, Debug)] pub struct QueryBonds(pub args::QueryBonds); @@ -6322,6 +6352,41 @@ pub mod args { } } + impl CliToSdk> + for QueryRewardsEstimate + { + type Error = std::convert::Infallible; + + fn to_sdk( + self, + ctx: &mut Context, + ) -> Result, Self::Error> { + let query = self.query.to_sdk(ctx)?; + let chain_ctx = ctx.borrow_mut_chain_or_exit(); + + Ok(QueryRewardsEstimate:: { + query, + owner: chain_ctx.get_cached(&self.owner), + }) + } + } + + impl Args for QueryRewardsEstimate { + fn parse(matches: &ArgMatches) -> Self { + let query = Query::parse(matches); + let owner = VIEWING_KEY.parse(matches); + Self { query, owner } + } + + fn def(app: App) -> App { + app.add_args::>().arg( + VIEWING_KEY + .def() + .help(wrap!("The viewing key whose rewards to estimate.")), + ) + } + } + impl CliToSdk> for QueryBonds { type Error = std::convert::Infallible; diff --git a/crates/apps_lib/src/cli/client.rs b/crates/apps_lib/src/cli/client.rs index d755c7d7f4..6542a55b63 100644 --- a/crates/apps_lib/src/cli/client.rs +++ b/crates/apps_lib/src/cli/client.rs @@ -551,6 +551,18 @@ impl CliApi { let namada = ctx.to_sdk(client, io); rpc::query_balance(&namada, args).await; } + Sub::QueryRewardsEstimate(QueryRewardsEstimate(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 args = args.to_sdk(&mut ctx)?; + let namada = ctx.to_sdk(client, io); + rpc::query_rewards_estimate(&namada, args).await; + } Sub::QueryBonds(QueryBonds(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 7814c2db31..f44fc4023e 100644 --- a/crates/apps_lib/src/client/rpc.rs +++ b/crates/apps_lib/src/client/rpc.rs @@ -11,6 +11,7 @@ use masp_primitives::sapling::Node; use masp_primitives::transaction::components::I128Sum; use masp_primitives::zip32::ExtendedFullViewingKey; use namada_core::masp::{BalanceOwner, MaspEpoch}; +use namada_core::token::Amount; use namada_sdk::address::{Address, InternalAddress, MASP}; use namada_sdk::chain::{BlockHeight, Epoch}; use namada_sdk::collections::{HashMap, HashSet}; @@ -46,7 +47,9 @@ use namada_sdk::rpc::{ }; use namada_sdk::storage::BlockResults; use namada_sdk::tendermint_rpc::endpoint::status; -use namada_sdk::token::{DenominatedAmount, MaspDigitPos}; +use namada_sdk::token::{ + DenominatedAmount, MaspDigitPos, NATIVE_MAX_DECIMAL_PLACES, +}; use namada_sdk::tx::display_batch_resp; use namada_sdk::wallet::AddressVpType; use namada_sdk::{error, state as storage, token, Namada}; @@ -359,6 +362,29 @@ pub async fn query_proposal_by_id( namada_sdk::rpc::query_proposal_by_id(client, proposal_id).await } +/// Estimate MASP rewards for next MASP epoch +pub async fn query_rewards_estimate( + context: &impl Namada, + args: args::QueryRewardsEstimate, +) { + let mut shielded = context.shielded_mut().await; + let _ = shielded.load().await; + let rewards_estimate = shielded + .estimate_next_epoch_rewards(context, &args.owner.as_viewing_key()) + .await + .unwrap() + .unsigned_abs(); + let rewards_estimate = DenominatedAmount::new( + Amount::from_u128(rewards_estimate), + NATIVE_MAX_DECIMAL_PLACES.into(), + ); + display_line!( + context.io(), + "Estimated native token rewards for the next MASP epoch: {}", + rewards_estimate + ); +} + /// Query token shielded balance(s) async fn query_shielded_balance( context: &impl Namada, diff --git a/crates/core/src/masp.rs b/crates/core/src/masp.rs index 4d727b3913..ea6d915e4d 100644 --- a/crates/core/src/masp.rs +++ b/crates/core/src/masp.rs @@ -144,6 +144,11 @@ impl MaspEpoch { Some(Self(self.0.checked_sub(1)?)) } + /// Change to the next masp epoch. + pub fn next(&self) -> Option { + Some(Self(self.0.checked_add(1)?)) + } + /// Initialize a new masp epoch from the provided one #[cfg(any(test, feature = "testing"))] pub const fn new(epoch: u64) -> Self { @@ -201,6 +206,15 @@ impl AssetData { } } + /// Update the MaspEpoch to the next one + pub fn redate_to_next_epoch(&mut self) { + if let Some(ep) = self.epoch.as_mut() { + if let Some(next) = ep.next() { + *ep = next; + } + } + } + /// Remove the epoch associated with this pre-asset type pub fn undate(&mut self) { self.epoch = None; diff --git a/crates/sdk/src/args.rs b/crates/sdk/src/args.rs index 9874d3aaf0..7d1c8c0831 100644 --- a/crates/sdk/src/args.rs +++ b/crates/sdk/src/args.rs @@ -1600,6 +1600,16 @@ pub struct QueryBalance { pub height: Option, } +/// Get an estimate for the MASP rewards accumulated by the next +/// MASP epoch. +#[derive(Clone, Debug)] +pub struct QueryRewardsEstimate { + /// Common query args + pub query: Query, + /// Viewing key + pub owner: C::ViewingKey, +} + /// Query historical transfer(s) #[derive(Clone, Debug)] pub struct QueryTransfers { diff --git a/crates/shielded_token/Cargo.toml b/crates/shielded_token/Cargo.toml index 126c543462..4a3d2ca58d 100644 --- a/crates/shielded_token/Cargo.toml +++ b/crates/shielded_token/Cargo.toml @@ -22,6 +22,7 @@ multicore = ["dep:rayon"] testing = [ "multicore", "namada_core/testing", + "namada_tx/testing", "masp_primitives/test-dependencies", "proptest", "std" @@ -92,5 +93,6 @@ masp_proofs = { workspace = true, features = ["download-params"] } proptest.workspace = true rand_core.workspace = true rayon.workspace = true +tendermint-rpc.workspace = true test-log.workspace = true tokio.workspace = true diff --git a/crates/shielded_token/src/masp/shielded_wallet.rs b/crates/shielded_token/src/masp/shielded_wallet.rs index 31cf50bf48..c43f4e7189 100644 --- a/crates/shielded_token/src/masp/shielded_wallet.rs +++ b/crates/shielded_token/src/masp/shielded_wallet.rs @@ -700,6 +700,97 @@ pub trait ShieldedApi: } } + /// We estimate the total rewards accumulated by the assets owned by + /// the provided viewing key. This is done by assuming the same rewards + /// rate on each asset as in the latest masp epoch. + #[allow(async_fn_in_trait)] + async fn estimate_next_epoch_rewards( + &mut self, + context: &impl NamadaIo, + vk: &ViewingKey, + ) -> Result { + let native_token = Self::query_native_token(context.client()).await?; + let current_epoch = Self::query_masp_epoch(context.client()).await?; + let target_epoch = current_epoch + .next() + .ok_or_else(|| eyre!("The final MASP epoch is already afoot."))?; + // get the raw balance of the notes associated with this key + if let Some(balance) = self.compute_shielded_balance(vk).await? { + // convert amount and get used conversions + let mut conversions = self + .compute_exchanged_amount( + context.client(), + context.io(), + balance.clone(), + target_epoch, + Conversions::new(), + ) + .await? + .1; + + // re-date the all the latest conversions up one epoch + let mut estimated_conversions = Conversions::new(); + for (asset_type, (conv, wit, _)) in &conversions { + let mut asset = match self + .decode_asset_type(context.client(), *asset_type) + .await + { + Some( + data @ AssetData { + epoch: Some(ep), .. + }, + ) if ep.next() == Some(current_epoch) => data, + _ => continue, + }; + asset.redate_to_next_epoch(); + let decoded_conv = self + .decode_sum(context.client(), conv.clone().into()) + .await + .0; + let mut est_conv = I128Sum::zero(); + for ((_, asset_data), val) in decoded_conv.components() { + let mut new_asset = asset_data.clone(); + if new_asset.epoch != Some(MaspEpoch::zero()) { + new_asset.redate_to_next_epoch(); + } + est_conv += ValueSum::from_pair(new_asset.encode()?, *val) + } + estimated_conversions.insert( + asset.encode().unwrap(), + (AllowedConversion::from(est_conv), wit.clone(), 0), + ); + } + conversions.extend(estimated_conversions); + // use the estimations to convert the amount + let exchanged_amount = self + .compute_exchanged_amount( + context.client(), + context.io(), + balance.clone(), + target_epoch, + conversions, + ) + .await? + .0; + + let rewards = exchanged_amount - balance; + // sum up the rewards. + Ok(self + .decode_sum(context.client(), rewards) + .await + .0 + .components() + .filter(|((_, data), _)| { + // this should always be true, but we check it anyway + data.token == native_token + }) + .map(|(_, val)| *val) + .sum::()) + } else { + Ok(0) + } + } + /// Collect enough unspent notes in this context to exceed the given amount /// of the specified asset type. Return the total value accumulated plus /// notes and the corresponding diversifiers/merkle paths that were used to @@ -1658,3 +1749,545 @@ impl> ShieldedApi for T { } + +#[cfg(test)] +mod test_shielded_wallet { + use namada_core::address::InternalAddress; + use namada_core::borsh::BorshSerializeExt; + use namada_core::masp::AssetData; + use namada_core::token::MaspDigitPos; + use namada_io::NamadaIo; + use proptest::proptest; + use tempfile::tempdir; + + use super::*; + use crate::masp::fs::FsShieldedUtils; + use crate::masp::test_utils::{ + arbitrary_pa, arbitrary_vk, create_note, MockNamadaIo, TestingContext, + }; + + #[tokio::test] + async fn test_compute_shielded_balance() { + let (_client_channel, context) = MockNamadaIo::new(); + let temp_dir = tempdir().unwrap(); + let mut wallet = TestingContext::new(FsShieldedUtils::new( + temp_dir.path().to_path_buf(), + )); + let native_token = + TestingContext::::query_native_token( + context.client(), + ) + .await + .expect("Test failed"); + + let vk = arbitrary_vk(); + let pa = arbitrary_pa(); + let mut asset_data = AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: None, + }; + // check that if no notes are found, None is returned + let balance = wallet + .compute_shielded_balance(&vk) + .await + .expect("Test failed"); + assert!(balance.is_none()); + + // check that the correct balance if found for a single note + wallet.add_note(create_note(asset_data.clone(), 10, pa), vk); + let balance = wallet + .compute_shielded_balance(&vk) + .await + .expect("Test failed") + .expect("Test failed"); + let expected = + I128Sum::from_nonnegative(asset_data.encode().unwrap(), 10) + .expect("Test failed"); + assert_eq!(balance, expected); + + // check that multiple notes of the same asset are added together + let new_note = create_note(asset_data.clone(), 11, pa); + wallet.add_note(new_note, vk); + + let balance = wallet + .compute_shielded_balance(&vk) + .await + .expect("Test failed") + .expect("Test failed"); + let expected = + I128Sum::from_nonnegative(asset_data.encode().unwrap(), 21) + .expect("Test failed"); + assert_eq!(balance, expected); + + // check that spending a note works correctly + wallet.spend_note(&new_note); + + let balance = wallet + .compute_shielded_balance(&vk) + .await + .expect("Test failed") + .expect("Test failed"); + let expected = + I128Sum::from_nonnegative(asset_data.encode().unwrap(), 10) + .expect("Test failed"); + + assert_eq!(balance, expected); + + // check that the balance does not add together non-fungible asset types + asset_data.epoch = Some(MaspEpoch::new(1)); + wallet.add_note(create_note(asset_data.clone(), 7, pa), vk); + let balance = wallet + .compute_shielded_balance(&vk) + .await + .expect("Test failed") + .expect("Test failed"); + let expected = expected + + I128Sum::from_nonnegative(asset_data.encode().unwrap(), 7) + .expect("Test failed"); + + assert_eq!(balance, expected); + assert_eq!(balance.components().count(), 2); + + // check that a missing index causes an error + wallet.note_map.clear(); + assert!(wallet.compute_shielded_balance(&vk).await.is_err()) + } + + #[tokio::test] + async fn test_estimate_rewards_no_conversions() { + let (channel, context) = MockNamadaIo::new(); + // the response to the current masp epoch query + channel + .send(MaspEpoch::new(1).serialize_to_vec()) + .expect("Test failed"); + let temp_dir = tempdir().unwrap(); + let mut wallet = TestingContext::new(FsShieldedUtils::new( + temp_dir.path().to_path_buf(), + )); + + let native_token = + TestingContext::::query_native_token( + context.client(), + ) + .await + .expect("Test failed"); + + let vk = arbitrary_vk(); + let pa = arbitrary_pa(); + let asset_data = AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(1)), + }; + wallet.add_asset_type(asset_data.clone()); + wallet.add_note(create_note(asset_data.clone(), 10, pa), vk); + let rewards_est = wallet + .estimate_next_epoch_rewards(&context, &vk) + .await + .expect("Test failed"); + assert_eq!(rewards_est, 0); + } + + proptest! { + /// In this test, we have a single incentivized token + /// shielded at MaspEpoch(1) owned by the shielded wallet. + /// The amount of owned token is the parameter `principal`. + /// + /// We add a conversion from MaspEpoch(1) to MaspEpoch(2) + /// which issues `reward_rate` nam tokens for each of our + /// incentivized token. + /// + /// We test that estimating the rewards for MaspEpoch(3) + /// applies the same conversions as the last epoch, yielding + /// a total reward estimation of 2 * principal * reward_rate. + /// + /// Furthermore, we own `rewardless` amount of a token that + /// is not incentivized and thus should not contribute to + /// rewards. + #[test] + fn test_estimate_rewards_with_conversions( + // fairly arbitrary upper bounds, but they are large + // and guaranteed that 2 * reward_rate * principal + // does not exceed 64 bits + principal in 1u64 .. 100_000, + reward_rate in 1i128 .. 1_000, + rewardless in 1u64 .. 100_000, + ) { + // #[tokio::test] doesn't work with the proptest! macro + tokio::runtime::Runtime::new().unwrap().block_on(async { + + let (channel, mut context) = MockNamadaIo::new(); + // the response to the current masp epoch query + channel.send(MaspEpoch::new(2).serialize_to_vec()).expect("Test failed"); + let temp_dir = tempdir().unwrap(); + let mut wallet = TestingContext::new(FsShieldedUtils::new( + temp_dir.path().to_path_buf(), + )); + + let native_token = + TestingContext::::query_native_token( + context.client(), + ) + .await + .expect("Test failed"); + + // we use a random addresses as our token + let incentivized_token = Address::Internal(InternalAddress::Pgf); + let unincentivized = Address::Internal(InternalAddress::ReplayProtection); + + // add asset type decodings + wallet.add_asset_type(AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(0)), + }); + wallet.add_asset_type(AssetData { + token: unincentivized.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: None, + }); + + for epoch in 0..4 { + wallet.add_asset_type(AssetData { + token: incentivized_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(epoch)), + }); + } + + // add conversions for the incentivized tokens + let mut conv = I128Sum::from_pair( + AssetData { + token: incentivized_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(1)), + }.encode().unwrap(), + -1, + ); + conv += I128Sum::from_pair( + AssetData { + token: incentivized_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(2)), + }.encode().unwrap(), + 1, + ); + conv += I128Sum::from_pair( + AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(0)), + }.encode().unwrap(), + reward_rate, + ); + context.add_conversions( + AssetData { + token: incentivized_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(1)), + }, + ( + incentivized_token.clone(), + 0.into(), + MaspDigitPos::Zero, + MaspEpoch::new(1), + conv, + MerklePath::from_path(vec![], 0), + ) + ); + + let vk = arbitrary_vk(); + let pa = arbitrary_pa(); + let asset_data = AssetData { + token: incentivized_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(1)), + }; + + wallet.add_note( + create_note(asset_data.clone(), principal, pa), + vk, + ); + + // add an unincentivized token which should not contribute + // to the rewards + let asset_data = AssetData { + token: unincentivized.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: None, + }; + + wallet.add_note( + create_note(asset_data.clone(), rewardless, pa), + vk, + ); + let rewards_est = wallet.estimate_next_epoch_rewards(&context, &vk).await.expect("Test failed"); + assert_eq!(rewards_est, 2 * reward_rate * i128::from(principal)); + }); + } + + /// A more complicated test that checks asset estimations when multiple + /// different incentivized assets are present and multiple conversions need + /// to be applied to the same note. + #[test] + fn test_ests_with_mult_incentivized_assets( + principal1 in 1u64..10_000, + principal2 in 1u64..10_000, + tok1_reward_rate in 1i128..1000, + tok2_reward_rate in 1i128..1000, + ) { + + // #[tokio::test] doesn't work with the proptest! macro + tokio::runtime::Runtime::new().unwrap().block_on(async { + let (channel, mut context) = MockNamadaIo::new(); + // the response to the current masp epoch query + channel + .send(MaspEpoch::new(3).serialize_to_vec()) + .expect("Test failed"); + let temp_dir = tempdir().unwrap(); + let mut wallet = TestingContext::new(FsShieldedUtils::new( + temp_dir.path().to_path_buf(), + )); + + let native_token = + TestingContext::::query_native_token( + context.client(), + ) + .await + .expect("Test failed"); + + // we use a random addresses as our tokens + let tok1 = Address::Internal(InternalAddress::Pgf); + let tok2 = Address::Internal(InternalAddress::ReplayProtection); + + // add asset type decodings + wallet.add_asset_type(AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(0)), + }); + + for tok in [&tok1, &tok2] { + for epoch in 0..5 { + wallet.add_asset_type(AssetData { + token: tok.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(epoch)), + }); + } + } + // add conversions from epoch 1 -> 2 for tok1 + let mut conv = I128Sum::from_pair( + AssetData { + token: tok1.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(1)), + } + .encode() + .unwrap(), + -1, + ); + conv += I128Sum::from_pair( + AssetData { + token: tok1.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(2)), + } + .encode() + .unwrap(), + 1, + ); + conv += I128Sum::from_pair( + AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(0)), + } + .encode() + .unwrap(), + tok1_reward_rate, + ); + context.add_conversions( + AssetData { + token: tok1.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(1)), + }, + ( + tok1.clone(), + 0.into(), + MaspDigitPos::Zero, + MaspEpoch::new(1), + conv, + MerklePath::from_path(vec![], 0), + ), + ); + + // add conversions from epoch 2 -> 3 for tok1 + let mut conv = I128Sum::from_pair( + AssetData { + token: tok1.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(2)), + } + .encode() + .unwrap(), + -1, + ); + conv += I128Sum::from_pair( + AssetData { + token: tok1.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(3)), + } + .encode() + .unwrap(), + 1, + ); + conv += I128Sum::from_pair( + AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(0)), + } + .encode() + .unwrap(), + 1, + ); + context.add_conversions( + AssetData { + token: tok1.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(2)), + }, + ( + tok1.clone(), + 0.into(), + MaspDigitPos::Zero, + MaspEpoch::new(2), + conv, + MerklePath::from_path(vec![], 0), + ), + ); + // add conversions from epoch 2 -> 3 for tok2 + let mut conv = I128Sum::from_pair( + AssetData { + token: tok2.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(2)), + } + .encode() + .unwrap(), + -1, + ); + conv += I128Sum::from_pair( + AssetData { + token: tok2.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(3)), + } + .encode() + .unwrap(), + 1, + ); + conv += I128Sum::from_pair( + AssetData { + token: native_token.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(0)), + } + .encode() + .unwrap(), + tok2_reward_rate, + ); + context.add_conversions( + AssetData { + token: tok2.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(2)), + }, + ( + tok2.clone(), + 0.into(), + MaspDigitPos::Zero, + MaspEpoch::new(2), + conv, + MerklePath::from_path(vec![], 0), + ), + ); + + // create note with tok1 + let vk = arbitrary_vk(); + let pa = arbitrary_pa(); + let asset_data = AssetData { + token: tok1.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(1)), + }; + + wallet.add_note( + create_note(asset_data.clone(), principal1, pa), + vk, + ); + + // create note with tok2 + let asset_data = AssetData { + token: tok2.clone(), + denom: 0.into(), + position: MaspDigitPos::Zero, + epoch: Some(MaspEpoch::new(2)), + }; + + wallet.add_note( + create_note(asset_data.clone(), principal2, pa), + vk, + ); + + let rewards_est = wallet + .estimate_next_epoch_rewards(&context, &vk) + .await + .expect("Test failed"); + let principal1 = i128::from(principal1); + let principal2 = i128::from(principal2); + // reward from epoch 1->2 + reward from epoch 2->3 + reward from + // epoch 2->3 + let expected_tok1_rewards = + principal1 * tok1_reward_rate + principal1 + principal1; + // reward from epoch 2->3 + reward from epoch 2->3 + let expected_tok2_rewards = + principal2 * tok2_reward_rate + principal2 * tok2_reward_rate; + assert_eq!( + rewards_est, + expected_tok1_rewards + expected_tok2_rewards + ); + }); + } + } +} diff --git a/crates/shielded_token/src/masp/test_utils.rs b/crates/shielded_token/src/masp/test_utils.rs index a98f68ca46..f19187a530 100644 --- a/crates/shielded_token/src/masp/test_utils.rs +++ b/crates/shielded_token/src/masp/test_utils.rs @@ -1,25 +1,47 @@ use core::str::FromStr; use std::collections::BTreeMap; +use std::sync::Arc; use borsh::BorshDeserialize; -use masp_primitives::merkle_tree::{CommitmentTree, IncrementalWitness}; -use masp_primitives::sapling::{Node, ViewingKey}; +use eyre::eyre; +use masp_primitives::asset_type::AssetType; +use masp_primitives::merkle_tree::{ + CommitmentTree, IncrementalWitness, MerklePath, +}; +use masp_primitives::sapling::{Node, Note, Rseed, ViewingKey}; +use masp_primitives::transaction::components::I128Sum; use masp_primitives::transaction::Transaction; use masp_primitives::zip32::ExtendedFullViewingKey; +use namada_core::address::Address; +use namada_core::borsh::BorshSerializeExt; use namada_core::chain::BlockHeight; use namada_core::collections::HashMap; -use namada_core::masp::ExtendedViewingKey; +use namada_core::masp::{ + AssetData, ExtendedViewingKey, MaspEpoch, PaymentAddress, +}; +use namada_core::time::DurationSecs; +use namada_core::token::{Denomination, MaspDigitPos}; +use namada_io::client::EncodedResponseQuery; +use namada_io::{Client, MaybeSend, MaybeSync, NamadaIo, NullIo}; use namada_tx::IndexedTx; use namada_wallet::DatedKeypair; use thiserror::Error; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tokio::sync::Mutex; +use crate::masp::shielded_wallet::ShieldedQueries; use crate::masp::utils::{ IndexedNoteEntry, MaspClient, MaspClientCapabilities, }; +use crate::masp::ShieldedUtils; +use crate::ShieldedWallet; /// A viewing key derived from A_SPENDING_KEY pub const AA_VIEWING_KEY: &str = "zvknam1qqqqqqqqqqqqqq9v0sls5r5de7njx8ehu49pqgmqr9ygelg87l5x8y4s9r0pjlvu6x74w9gjpw856zcu826qesdre628y6tjc26uhgj6d9zqur9l5u3p99d9ggc74ald6s8y3sdtka74qmheyqvdrasqpwyv2fsmxlz57lj4grm2pthzj3sflxc0jx0edrakx3vdcngrfjmru8ywkguru8mxss2uuqxdlglaz6undx5h8w7g70t2es850g48xzdkqay5qs0yw06rtxcpjdve6"; +// A payment address derived from A_SPENDING_KEY +pub const AA_PAYMENT_ADDRESS: &str = "znam1ky620tz7z658cralqt693qpvk42wvth468zp38nqvq2apmex5rfut3dfqm2asrsqv0tc7saqje7"; + pub fn dated_arbitrary_vk() -> DatedKeypair { arbitrary_vk().into() } @@ -32,6 +54,10 @@ pub fn arbitrary_vk() -> ViewingKey { .vk } +pub fn arbitrary_pa() -> PaymentAddress { + FromStr::from_str(AA_PAYMENT_ADDRESS).expect("Test failed") +} + /// A serialized transaction that will work for testing. /// Would love to do this in a less opaque fashion, but /// making these things is a misery not worth my time. @@ -414,3 +440,241 @@ impl MaspClient for TestingMaspClient { unimplemented!("Witness map fetching is not implemented by this client") } } + +/// A shielded context for testing +#[derive(Debug)] +pub struct TestingContext { + wallet: ShieldedWallet, +} + +impl TestingContext { + pub fn new(wallet: ShieldedWallet) -> Self { + Self { wallet } + } + + pub fn add_asset_type(&mut self, asset_data: AssetData) { + self.asset_types + .insert(asset_data.encode().unwrap(), asset_data); + } + + /// Add a note to a given viewing key + pub fn add_note(&mut self, note: Note, vk: ViewingKey) { + let next_note_idx = self + .wallet + .note_map + .keys() + .max() + .map(|ix| ix + 1) + .unwrap_or_default(); + self.wallet.note_map.insert(next_note_idx, note); + let avail_notes = self.wallet.pos_map.entry(vk).or_default(); + avail_notes.insert(next_note_idx); + } + + pub fn spend_note(&mut self, note: &Note) { + let idx = self + .wallet + .note_map + .iter() + .find(|(_, v)| *v == note) + .map(|(idx, _)| idx) + .expect("Could find the note to spend in the note map"); + self.wallet.spents.insert(*idx); + } +} + +impl std::ops::Deref + for TestingContext +{ + type Target = ShieldedWallet; + + fn deref(&self) -> &Self::Target { + &self.wallet + } +} + +impl std::ops::DerefMut + for TestingContext +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.wallet + } +} + +impl ShieldedQueries + for TestingContext +{ + async fn query_native_token( + _: &C, + ) -> Result { + Ok(Address::Established([0u8; 20].into())) + } + + async fn query_denom( + client: &C, + token: &Address, + ) -> Option { + Some( + if *token + == Self::query_native_token(client).await.expect("Infallible") + { + Denomination(6) + } else { + Denomination(0) + }, + ) + } + + async fn query_conversion( + client: &C, + asset_type: AssetType, + ) -> Option { + let resp = client + .request(asset_type.to_string(), None, None, false) + .await + .ok()?; + BorshDeserialize::try_from_slice(&resp.data).unwrap() + } + + async fn query_block( + _: &C, + ) -> Result, eyre::Error> { + unimplemented!() + } + + async fn query_max_block_time_estimate( + _: &C, + ) -> Result { + unimplemented!() + } + + async fn query_masp_epoch( + client: &C, + ) -> Result { + let resp = client + .request("".to_string(), None, None, false) + .await + .map_err(|e| eyre!("{}", e))?; + BorshDeserialize::try_from_slice(&resp.data).map_err(|e| eyre!("{}", e)) + } +} + +pub type ConversionResp = ( + Address, + Denomination, + MaspDigitPos, + MaspEpoch, + I128Sum, + MerklePath, +); + +/// A mock client for making "queries" on behalf +/// of a `TestingContext` +pub struct MockClient { + channel: Arc>>>, + pub conversions: HashMap, +} + +impl MockClient { + pub fn new() -> (UnboundedSender>, Self) { + let (send, recv) = tokio::sync::mpsc::unbounded_channel(); + ( + send, + Self { + channel: Arc::new(Mutex::new(recv)), + conversions: Default::default(), + }, + ) + } +} + +#[cfg(test)] +#[cfg_attr(feature = "async-send", async_trait::async_trait)] +#[cfg_attr(not(feature = "async-send"), async_trait::async_trait(?Send))] +impl Client for MockClient { + type Error = eyre::Error; + + async fn request( + &self, + req: String, + _: Option>, + _: Option, + _: bool, + ) -> Result { + let resp = if let Ok(asset_type) = AssetType::from_str(&req) { + self.conversions.get(&asset_type).serialize_to_vec() + } else { + let mut locked = self.channel.lock().await; + locked + .recv() + .await + .ok_or_else(|| eyre!("Client did not respond"))? + }; + Ok(EncodedResponseQuery { + data: resp, + info: "".to_string(), + proof: None, + height: Default::default(), + }) + } + + async fn perform( + &self, + _: R, + ) -> Result + where + R: tendermint_rpc::request::SimpleRequest, + { + unimplemented!() + } +} + +pub struct MockNamadaIo { + client: MockClient, + io: NullIo, +} + +impl MockNamadaIo { + pub fn new() -> (UnboundedSender>, Self) { + let (send, client) = MockClient::new(); + (send, Self { client, io: NullIo }) + } + + pub fn add_conversions( + &mut self, + asset_data: AssetData, + conv: ConversionResp, + ) { + self.client + .conversions + .insert(asset_data.encode().unwrap(), conv); + } +} + +impl NamadaIo for MockNamadaIo { + type Client = MockClient; + type Io = NullIo; + + fn client(&self) -> &Self::Client { + &self.client + } + + fn io(&self) -> &Self::Io { + &self.io + } +} + +pub fn create_note( + asset_data: AssetData, + value: u64, + pa: PaymentAddress, +) -> Note { + let payment_addr: masp_primitives::sapling::PaymentAddress = pa.into(); + Note { + value, + g_d: payment_addr.g_d().unwrap(), + pk_d: *payment_addr.pk_d(), + asset_type: asset_data.encode().unwrap(), + rseed: Rseed::AfterZip212([0; 32]), + } +} diff --git a/crates/tests/src/integration/masp.rs b/crates/tests/src/integration/masp.rs index 9cd8aaf07f..1cd8a9e2cd 100644 --- a/crates/tests/src/integration/masp.rs +++ b/crates/tests/src/integration/masp.rs @@ -1529,6 +1529,27 @@ fn masp_incentives() -> Result<()> { assert!(captured.result.is_ok()); assert!(captured.contains("nam: 0")); + // Assert the rewards estimate is also zero + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "estimate-rewards", + "--key", + AA_VIEWING_KEY, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!( + captured.contains( + "Estimated native token rewards for the next MASP epoch: 0" + ) + ); + // Wait till epoch boundary node.next_masp_epoch(); @@ -1578,6 +1599,26 @@ fn masp_incentives() -> Result<()> { assert!(captured.result.is_ok()); assert!(captured.contains("nam: 0.063")); + // Assert the rewards estimate is a number higher than the actual rewards + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "estimate-rewards", + "--key", + AA_VIEWING_KEY, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + // note that 0.126 = 2 * 0.063 which is expected + assert!(captured.contains( + "Estimated native token rewards for the next MASP epoch: 0.126" + )); + // Assert NAM balance at MASP pool is exclusively the // rewards from the shielded BTC let captured = CapturedOutput::of(|| {