diff --git a/.changelog/unreleased/features/4790-frontend-sus-fees.md b/.changelog/unreleased/features/4790-frontend-sus-fees.md new file mode 100644 index 00000000000..f54c71cf27a --- /dev/null +++ b/.changelog/unreleased/features/4790-frontend-sus-fees.md @@ -0,0 +1,2 @@ +- Added support for MASP frontend providers sustainability fees. + ([\#4790](https://github.com/anoma/namada/pull/4790)) \ No newline at end of file diff --git a/.github/workflows/scripts/e2e.json b/.github/workflows/scripts/e2e.json index e542f119779..d965f0bdc55 100644 --- a/.github/workflows/scripts/e2e.json +++ b/.github/workflows/scripts/e2e.json @@ -1,5 +1,6 @@ { "e2e::eth_bridge_tests::everything": 4, + "e2e::ibc_tests::frontend_sus_fee": 350, "e2e::ibc_tests::ibc_transfers": 414, "e2e::ibc_tests::ibc_nft_transfers": 224, "e2e::ibc_tests::pgf_over_ibc": 415, diff --git a/crates/apps_lib/src/cli.rs b/crates/apps_lib/src/cli.rs index baf03a69597..32ec3e07b05 100644 --- a/crates/apps_lib/src/cli.rs +++ b/crates/apps_lib/src/cli.rs @@ -318,7 +318,9 @@ pub mod cmds { Self::parse_with_ctx(matches, TxShieldingTransfer); let tx_unshielding_transfer = Self::parse_with_ctx(matches, TxUnshieldingTransfer); - let tx_ibc_transfer = Self::parse_with_ctx(matches, TxIbcTransfer); + let tx_ibc_transfer = Self::parse_with_ctx(matches, |cmd| { + TxIbcTransfer(Box::new(cmd)) + }); let tx_osmosis_swap = Self::parse_with_ctx(matches, |cmd| { TxOsmosisSwap(Box::new(cmd)) }); @@ -507,7 +509,7 @@ pub mod cmds { TxShieldedTransfer(TxShieldedTransfer), TxShieldingTransfer(TxShieldingTransfer), TxUnshieldingTransfer(TxUnshieldingTransfer), - TxIbcTransfer(TxIbcTransfer), + TxIbcTransfer(Box), TxOsmosisSwap(Box), QueryResult(QueryResult), TxUpdateAccount(TxUpdateAccount), @@ -3691,6 +3693,14 @@ pub mod args { pub const TARGET: Arg = arg("target"); pub const TARGET_OPT: ArgOpt = arg_opt("target"); pub const TEMPLATES_PATH: Arg = arg("templates-path"); + // WARNING: use only for testing purposes, MASP frontend fees don't make + // sense when operating from the CLI + pub const __TEST_FRONTEND_SUS_FEE: ArgOpt = + arg_opt("test-frontend-sus-fee"); + // WARNING: use only for testing purposes, MASP frontend fees don't make + // sense when operating from the CLI + pub const __TEST_FRONTEND_SUS_FEE_IBC: ArgOpt = + arg_opt("test-frontend-sus-fee-ibc"); pub const TIMEOUT_HEIGHT: ArgOpt = arg_opt("timeout-height"); pub const TIMEOUT_SEC_OFFSET: ArgOpt = arg_opt("timeout-sec-offset"); pub const TM_ADDRESS_OPT: ArgOpt = arg_opt("tm-address"); @@ -4928,11 +4938,11 @@ pub mod args { ctx: &mut Context, ) -> Result, Self::Error> { let tx = self.tx.to_sdk(ctx)?; - let mut data = vec![]; + let mut sources = vec![]; let chain_ctx = ctx.borrow_mut_chain_or_exit(); for transfer_data in self.sources { - data.push(TxTransparentSource { + sources.push(TxTransparentSource { source: chain_ctx.get(&transfer_data.source), token: chain_ctx.get(&transfer_data.token), amount: transfer_data.amount, @@ -4947,12 +4957,17 @@ pub mod args { amount: transfer_data.amount, }); } + let frontend_sus_fee = + self.frontend_sus_fee.map(|(target, percentage)| { + (chain_ctx.get(&target), percentage) + }); Ok(TxShieldingTransfer:: { tx, - sources: data, + sources, targets, tx_code_path: self.tx_code_path.to_path_buf(), + frontend_sus_fee, }) } } @@ -4963,24 +4978,31 @@ pub mod args { let source = SOURCE.parse(matches); let target = PAYMENT_ADDRESS_TARGET.parse(matches); let token = TOKEN.parse(matches); - let amount = InputAmount::Unvalidated(AMOUNT.parse(matches)); + let raw_amount = AMOUNT.parse(matches); + let amount = InputAmount::Unvalidated(raw_amount); let tx_code_path = PathBuf::from(TX_TRANSFER_WASM); - let data = vec![TxTransparentSource { - source, + let sources = vec![TxTransparentSource { + source: source.clone(), token: token.clone(), amount, }]; let targets = vec![TxShieldedTarget { target, - token, + token: token.clone(), amount, }]; + let frontend_sus_fee = + __TEST_FRONTEND_SUS_FEE.parse(matches).map(|target| { + // Take a constant fee of 10% on top of the input amount + (target, Dec::new(1, 1).unwrap()) + }); Self { tx, - sources: data, + sources, targets, tx_code_path, + frontend_sus_fee, } } @@ -5001,6 +5023,15 @@ pub mod args { .def() .help(wrap!("The amount to transfer in decimal.")), ) + .arg( + __TEST_FRONTEND_SUS_FEE + .def() + .help(wrap!( + "The optional address of the frontend provider \ + that will take the masp sustainability fee." + )) + .hide(true), + ) } } @@ -5033,6 +5064,12 @@ pub mod args { amount: transfer_data.amount, }); } + + let frontend_sus_fee = + self.frontend_sus_fee.map(|(target, percentage)| { + (chain_ctx.get(&target), percentage) + }); + let gas_spending_key = self.gas_spending_key.map(|key| chain_ctx.get_cached(&key)); @@ -5042,6 +5079,7 @@ pub mod args { gas_spending_key, sources, tx_code_path: self.tx_code_path.to_path_buf(), + frontend_sus_fee, }) } } @@ -5052,26 +5090,34 @@ pub mod args { let source = SPENDING_KEY_SOURCE.parse(matches); let target = TARGET.parse(matches); let token = TOKEN.parse(matches); - let amount = InputAmount::Unvalidated(AMOUNT.parse(matches)); + let raw_amount = AMOUNT.parse(matches); + let amount = InputAmount::Unvalidated(raw_amount); let tx_code_path = PathBuf::from(TX_TRANSFER_WASM); - let data = vec![TxTransparentTarget { - target, + let targets = vec![TxTransparentTarget { + target: target.clone(), token: token.clone(), amount, }]; let sources = vec![TxShieldedSource { - source, - token, + source: source.clone(), + token: token.clone(), amount, }]; let gas_spending_key = GAS_SPENDING_KEY.parse(matches); + let frontend_sus_fee = + __TEST_FRONTEND_SUS_FEE.parse(matches).map(|target| { + // Take a constant fee of 10% on top of the input amount + (target, Dec::new(1, 1).unwrap()) + }); + Self { tx, sources, - targets: data, + targets, gas_spending_key, tx_code_path, + frontend_sus_fee, } } @@ -5098,6 +5144,15 @@ pub mod args { payment. When not provided the source spending key will \ be used." ))) + .arg( + __TEST_FRONTEND_SUS_FEE + .def() + .help(wrap!( + "The optional address of the frontend provider \ + that will take the masp sustainability fee." + )) + .hide(true), + ) } } @@ -5112,6 +5167,10 @@ pub mod args { let chain_ctx = ctx.borrow_mut_chain_or_exit(); let gas_spending_key = self.gas_spending_key.map(|key| chain_ctx.get_cached(&key)); + let frontend_sus_fee = + self.frontend_sus_fee.map(|(target, percentage)| { + (chain_ctx.get(&target), percentage) + }); Ok(TxIbcTransfer:: { tx, @@ -5128,6 +5187,7 @@ pub mod args { ibc_memo: self.ibc_memo, gas_spending_key, tx_code_path: self.tx_code_path.to_path_buf(), + frontend_sus_fee, }) } } @@ -5138,7 +5198,9 @@ pub mod args { let source = TRANSFER_SOURCE.parse(matches); let receiver = RECEIVER.parse(matches); let token = TOKEN.parse(matches); - let amount = InputAmount::Unvalidated(AMOUNT.parse(matches)); + + let raw_amount = AMOUNT.parse(matches); + let amount = InputAmount::Unvalidated(raw_amount); let port_id = PORT_ID.parse(matches); let channel_id = CHANNEL_ID.parse(matches); let timeout_height = TIMEOUT_HEIGHT.parse(matches); @@ -5154,6 +5216,12 @@ pub mod args { let ibc_memo = IBC_MEMO.parse(matches); let gas_spending_key = GAS_SPENDING_KEY.parse(matches); let tx_code_path = PathBuf::from(TX_IBC_WASM); + let frontend_sus_fee = + __TEST_FRONTEND_SUS_FEE.parse(matches).map(|target| { + // Take a constant fee of 10% on top of the input amount + (target, Dec::new(1, 1).unwrap()) + }); + Self { tx, source, @@ -5169,6 +5237,7 @@ pub mod args { ibc_memo, gas_spending_key, tx_code_path, + frontend_sus_fee, } } @@ -5218,6 +5287,15 @@ pub mod args { payment (if this is a shielded action). When not \ provided the source spending key will be used." ))) + .arg( + __TEST_FRONTEND_SUS_FEE + .def() + .help(wrap!( + "The optional address of the frontend provider \ + that will take the masp sustainability fee." + )) + .hide(true), + ) } } @@ -5244,6 +5322,7 @@ pub mod args { route: self.route, osmosis_lcd_rpc: self.osmosis_lcd_rpc, osmosis_sqs_rpc: self.osmosis_sqs_rpc, + frontend_sus_fee: None, }) } } @@ -5296,6 +5375,7 @@ pub mod args { route, osmosis_lcd_rpc, osmosis_sqs_rpc, + frontend_sus_fee: None, } } @@ -7210,6 +7290,9 @@ pub mod args { ) -> Result, Self::Error> { let query = self.query.to_sdk(ctx)?; let chain_ctx = ctx.borrow_chain_or_exit(); + let frontend_sus_fee = self + .frontend_sus_fee + .map(|(target, amount)| (chain_ctx.get(&target), amount)); Ok(GenIbcShieldingTransfer:: { query, @@ -7231,6 +7314,7 @@ pub mod args { IbcShieldingTransferAsset::Address(chain_ctx.get(&addr)) } }, + frontend_sus_fee, }) } } @@ -7239,9 +7323,10 @@ pub mod args { fn parse(matches: &ArgMatches) -> Self { let query = Query::parse(matches); let output_folder = OUTPUT_FOLDER_PATH.parse(matches); - let target = TRANSFER_TARGET.parse(matches); + let target = PAYMENT_ADDRESS_TARGET.parse(matches); let token = TOKEN_STR.parse(matches); - let amount = InputAmount::Unvalidated(AMOUNT.parse(matches)); + let raw_amount = AMOUNT.parse(matches); + let amount = InputAmount::Unvalidated(raw_amount); let expiration = EXPIRATION_OPT.parse(matches); let port_id = PORT_ID.parse(matches); let channel_id = CHANNEL_ID.parse(matches); @@ -7254,6 +7339,12 @@ pub mod args { None => TxExpiration::Default, } }; + let frontend_sus_fee = __TEST_FRONTEND_SUS_FEE_IBC + .parse(matches) + .map(|payment_address| { + // Take a constant fee of 10% on top of the input amount + (payment_address, Dec::new(1, 1).unwrap()) + }); Self { query, @@ -7266,6 +7357,7 @@ pub mod args { channel_id, token, }, + frontend_sus_fee, } } @@ -7274,7 +7366,11 @@ pub mod args { .arg(OUTPUT_FOLDER_PATH.def().help(wrap!( "The output folder path where the artifact will be stored." ))) - .arg(TRANSFER_TARGET.def().help(wrap!("The target address."))) + .arg( + PAYMENT_ADDRESS_TARGET + .def() + .help(wrap!("The shielded target account address.")), + ) .arg(TOKEN.def().help(wrap!("The transfer token."))) .arg( AMOUNT @@ -7307,6 +7403,15 @@ pub mod args { .arg(CHANNEL_ID.def().help(wrap!( "The channel ID via which the token is received." ))) + .arg( + __TEST_FRONTEND_SUS_FEE_IBC + .def() + .help(wrap!( + "The optional address of the frontend provider \ + that will take the masp sustainability fee." + )) + .hide(true), + ) } } diff --git a/crates/apps_lib/src/cli/client.rs b/crates/apps_lib/src/cli/client.rs index 4faa979e4d8..c55dac91df3 100644 --- a/crates/apps_lib/src/cli/client.rs +++ b/crates/apps_lib/src/cli/client.rs @@ -100,7 +100,8 @@ impl CliApi { let namada = ctx.to_sdk(client, io); tx::submit_unshielding_transfer(&namada, args).await?; } - Sub::TxIbcTransfer(TxIbcTransfer(args)) => { + Sub::TxIbcTransfer(args) => { + let TxIbcTransfer(args) = *args; let chain_ctx = ctx.borrow_mut_chain_or_exit(); let ledger_address = chain_ctx.get(&args.tx.ledger_address); diff --git a/crates/sdk/src/args.rs b/crates/sdk/src/args.rs index c42ec761a0d..d70e035bc58 100644 --- a/crates/sdk/src/args.rs +++ b/crates/sdk/src/args.rs @@ -383,10 +383,12 @@ pub struct TxTransparentSource { pub struct TxShieldingTransfer { /// Common tx arguments pub tx: Tx, - /// Transfer target address + /// Transfer target data pub targets: Vec>, - /// Transfer-specific data + /// Transfer source data pub sources: Vec>, + /// The optional data for the frontend sustainability fee + pub frontend_sus_fee: Option<(C::TransferTarget, Dec)>, /// Path to the TX WASM code file pub tx_code_path: PathBuf, } @@ -430,12 +432,14 @@ pub struct TxTransparentTarget { pub struct TxUnshieldingTransfer { /// Common tx arguments pub tx: Tx, - /// Transfer source spending key + /// Transfer source data pub sources: Vec>, - /// Transfer-specific data + /// Transfer target data pub targets: Vec>, - /// Optional additional keys for gas payment + /// Optional additional key for gas payment pub gas_spending_key: Option, + /// The optional data for the frontend sustainability fee + pub frontend_sus_fee: Option<(C::TransferTarget, Dec)>, /// Path to the TX WASM code file pub tx_code_path: PathBuf, } @@ -534,6 +538,10 @@ pub struct TxOsmosisSwap { pub osmosis_lcd_rpc: Option, /// REST rpc endpoint to Osmosis SQS pub osmosis_sqs_rpc: Option, + /// The optional data for the frontend sustainability fee + /// NOTE: if the swap is shielded (from MASP to MASP), no sustainability + /// fee should be taken + pub frontend_sus_fee: Option<(C::PaymentAddress, Dec)>, } impl TxOsmosisSwap { @@ -601,6 +609,7 @@ impl TxOsmosisSwap { osmosis_lcd_rpc, osmosis_sqs_rpc, output_denom: namada_output_denom, + frontend_sus_fee, } = self; let osmosis_lcd_rpc = osmosis_lcd_rpc @@ -697,10 +706,7 @@ impl TxOsmosisSwap { ledger_address: transfer.tx.ledger_address.clone(), }, output_folder: None, - target: - namada_core::masp::TransferTarget::PaymentAddress( - payment_addr, - ), + target: payment_addr, asset: IbcShieldingTransferAsset::Address( namada_output_addr, ), @@ -711,6 +717,7 @@ impl TxOsmosisSwap { ), ), expiration: transfer.tx.expiration.clone(), + frontend_sus_fee, }, ) .await? @@ -810,6 +817,8 @@ pub struct TxIbcTransfer { pub ibc_memo: Option, /// Optional additional keys for gas payment pub gas_spending_key: Option, + /// The optional data for the frontend sustainability fee + pub frontend_sus_fee: Option<(C::TransferTarget, Dec)>, /// Path to the TX WASM code file pub tx_code_path: PathBuf, } @@ -3210,13 +3219,19 @@ pub struct GenIbcShieldingTransfer { /// The output directory path to where serialize the data pub output_folder: Option, /// The target address - pub target: C::TransferTarget, + pub target: C::PaymentAddress, /// Transferred token amount pub amount: InputAmount, /// The optional expiration of the masp shielding transaction pub expiration: TxExpiration, /// Asset to shield over IBC to Namada pub asset: IbcShieldingTransferAsset, + /// The optional data for the frontend sustainability fee (the target and + /// the amount, the token must be the same as the one involved in the + /// shielding transaction since ics-20 only supports a single asset) + /// NOTE: if the shielding operation is part of a swap, and this is + /// shielded (from MASP to MASP), no sustainability fee should be taken + pub frontend_sus_fee: Option<(C::PaymentAddress, Dec)>, } /// IBC shielding transfer asset, to be used by [`GenIbcShieldingTransfer`] diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 79f5e81987b..677f0bdffe3 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -183,12 +183,14 @@ pub trait Namada: NamadaIo { &self, targets: Vec, sources: Vec, + frontend_sus_fee: Option<(TransferTarget, Dec)>, ) -> args::TxShieldingTransfer { args::TxShieldingTransfer { sources, targets, tx_code_path: PathBuf::from(TX_TRANSFER_WASM), tx: self.tx_builder(), + frontend_sus_fee, } } @@ -199,6 +201,7 @@ pub trait Namada: NamadaIo { sources: Vec, targets: Vec, gas_spending_key: Option, + frontend_sus_fee: Option<(TransferTarget, Dec)>, ) -> args::TxUnshieldingTransfer { args::TxUnshieldingTransfer { sources, @@ -206,6 +209,7 @@ pub trait Namada: NamadaIo { gas_spending_key, tx_code_path: PathBuf::from(TX_TRANSFER_WASM), tx: self.tx_builder(), + frontend_sus_fee, } } @@ -293,6 +297,7 @@ pub trait Namada: NamadaIo { token: Address, amount: InputAmount, channel_id: ChannelId, + frontend_sus_fee: Option<(TransferTarget, Dec)>, ) -> args::TxIbcTransfer { args::TxIbcTransfer { source, @@ -309,6 +314,7 @@ pub trait Namada: NamadaIo { gas_spending_key: Default::default(), tx: self.tx_builder(), tx_code_path: PathBuf::from(TX_IBC_WASM), + frontend_sus_fee, } } diff --git a/crates/sdk/src/tx.rs b/crates/sdk/src/tx.rs index 94381b78506..6eec4e17925 100644 --- a/crates/sdk/src/tx.rs +++ b/crates/sdk/src/tx.rs @@ -27,7 +27,7 @@ use namada_core::address::{Address, IBC, MASP}; use namada_core::arith::checked; use namada_core::chain::Epoch; use namada_core::collections::HashSet; -use namada_core::dec::Dec; +use namada_core::dec::{Dec, POS_DECIMAL_PRECISION}; use namada_core::hash::Hash; use namada_core::ibc::apps::nft_transfer::types::PrefixedClassId; use namada_core::ibc::apps::nft_transfer::types::msgs::transfer::MsgTransfer as IbcMsgNftTransfer; @@ -2857,7 +2857,7 @@ pub async fn build_ibc_transfer( query_wasm_code_hash(context, args.tx_code_path.to_str().unwrap()) .await .map_err(|e| Error::from(QueryError::Wasm(e.to_string())))?; - let masp_transfer_data = MaspTransferData { + let mut masp_transfer_data = MaspTransferData { sources: vec![( args.source.clone(), args.token.clone(), @@ -2900,6 +2900,61 @@ pub async fn build_ibc_transfer( None }; + if let Some((target, percentage)) = &args.frontend_sus_fee { + match (&source, &args.ibc_shielding_data) { + (&MASP, None) => { + // NOTE: The frontend fee should NOT account for the masp fee + // payment amount + let validated_fee_amount = compute_masp_frontend_sus_fee( + context, + &validated_amount, + percentage, + &args.token, + args.tx.force, + ) + .await?; + + masp_transfer_data.sources.push(( + args.source.clone(), + args.token.clone(), + validated_fee_amount, + )); + masp_transfer_data.targets.push(( + target.clone(), + args.token.to_owned(), + validated_fee_amount, + )); + + transfer = transfer + .transfer( + source.to_owned(), + target.effective_address(), + args.token.to_owned(), + validated_fee_amount, + ) + .ok_or(Error::Other( + "Combined transfer overflows".to_string(), + ))?; + } + (&MASP, Some(_)) => { + return Err(Error::Other( + "A frontend sustainability fee was requested but the ibc \ + roundtrip is shielded" + .to_string(), + )); + } + (_, _) => { + return Err(Error::Other( + "A frontend sustainability fee was requested but the ibc \ + source is transparent. If the transaction is a roundtrip \ + (e.g. swap), and the return target is shielded, the fee \ + should be taken from the shielding transaction instead." + .to_string(), + )); + } + } + } + // For transfer from a spending key let shielded_parts = construct_shielded_parts( context, @@ -3557,10 +3612,49 @@ async fn get_masp_fee_payment_amount( }) } +// Extract the validate amount for the masp frontend sustainability fee +async fn compute_masp_frontend_sus_fee( + context: &impl Namada, + input_amount: &namada_token::DenominatedAmount, + percentage: &namada_core::dec::Dec, + token: &Address, + force: bool, +) -> Result { + let sus_fee_amt = namada_token::Amount::from_uint( + input_amount + .amount() + .raw_amount() + .checked_mul_div( + percentage.abs(), + namada_core::uint::Uint::exp10(POS_DECIMAL_PRECISION as _), + ) + .ok_or_else(|| { + Error::Other( + "Overflow in masp frontend fee computation".to_string(), + ) + })? + .0, + 0, + ) + .map_err(|e| Error::Other(e.to_string()))?; + + // Validate the amount given + validate_amount( + context, + args::InputAmount::Unvalidated(DenominatedAmount::new( + sus_fee_amt, + input_amount.denom(), + )), + token, + force, + ) + .await +} + /// Build a shielding transfer pub async fn build_shielding_transfer( context: &N, - args: &mut args::TxShieldingTransfer, + args: &args::TxShieldingTransfer, bparams: &mut impl BuildParams, ) -> Result<(Tx, SigningData, MaspEpoch)> { let source = if args.sources.len() == 1 { @@ -3602,6 +3696,35 @@ pub async fn build_shielding_transfer( validate_amount(context, amount.to_owned(), token, args.tx.force) .await?; + // Compute the frontend fee (if required), take the fee percentage from + // every source + let validated_frontend_fee_amt = if let Some(( + sus_fee_target, + percentage, + )) = &args.frontend_sus_fee + { + let validated_fee_amount = compute_masp_frontend_sus_fee( + context, + &validated_amount, + percentage, + token, + args.tx.force, + ) + .await?; + Some((sus_fee_target, validated_fee_amount)) + } else { + None + }; + let total_input_amt = checked!( + validated_amount + + validated_frontend_fee_amt + .map(|(_, amt)| amt) + .unwrap_or_else(|| DenominatedAmount::new( + namada_token::Amount::zero(), + validated_amount.denom() + )) + )?; + // Check the balance of the source if let Some(updated_balance) = &updated_balance { let check_balance = if &updated_balance.source == source @@ -3615,7 +3738,7 @@ pub async fn build_shielding_transfer( check_balance_too_low_err( token, source, - validated_amount.amount(), + total_input_amt.amount(), check_balance, args.tx.force, context, @@ -3630,8 +3753,35 @@ pub async fn build_shielding_transfer( )); data = data - .debit(source.to_owned(), token.to_owned(), validated_amount) + .debit(source.to_owned(), token.to_owned(), total_input_amt) .ok_or(Error::Other("Combined transfer overflows".to_string()))?; + + if let Some((sus_fee_target, validated_fee_amount)) = + validated_frontend_fee_amt + { + if sus_fee_target.payment_address().is_some() { + // Add the extra shielding source and target + transfer_data.sources.push(( + TransferSource::Address(source.to_owned()), + token.to_owned(), + validated_fee_amount, + )); + transfer_data.targets.push(( + sus_fee_target.to_owned(), + token.to_owned(), + validated_fee_amount, + )); + } + data = data + .credit( + sus_fee_target.effective_address(), + token.to_owned(), + validated_fee_amount, + ) + .ok_or(Error::Other( + "Combined transfer overflows".to_string(), + ))?; + } } for args::TxShieldedTarget { @@ -3784,6 +3934,43 @@ pub async fn build_unshielding_transfer( data = data .debit(MASP, token.to_owned(), validated_amount) .ok_or(Error::Other("Combined transfer overflows".to_string()))?; + + // Transfer the frontend fee (if required), take the fee percentage from + // every source + if let Some((sus_fee_target, percentage)) = &args.frontend_sus_fee { + // NOTE: The frontend fee should NOT account for the masp fee + // payment amount + let validated_fee_amount = compute_masp_frontend_sus_fee( + context, + &validated_amount, + percentage, + token, + args.tx.force, + ) + .await?; + data = data + .transfer( + MASP, + sus_fee_target.effective_address(), + token.to_owned(), + validated_fee_amount, + ) + .ok_or(Error::Other( + "Combined transfer overflows".to_string(), + ))?; + + // Add the extra unshielding source and target + transfer_data.sources.push(( + TransferSource::ExtendedKey(source.to_owned()), + token.to_owned(), + validated_fee_amount, + )); + transfer_data.targets.push(( + sus_fee_target.to_owned(), + token.to_owned(), + validated_fee_amount, + )); + } } // Add masp fee payment if necessary @@ -4334,14 +4521,48 @@ pub async fn gen_ibc_shielding_transfer( .precompute_asset_types(context.client(), tokens) .await; + let (extra_target, source_amount) = match &args.frontend_sus_fee { + Some((target, percentage)) => { + let validated_fee_amount = compute_masp_frontend_sus_fee( + context, + &validated_amount, + percentage, + &token, + false, + ) + .await?; + let source_amount = + checked!(validated_amount + validated_fee_amount)?; + + ( + vec![( + TransferTarget::PaymentAddress(target.to_owned()), + token.to_owned(), + validated_fee_amount, + )], + source_amount, + ) + } + None => (vec![], validated_amount), + }; + let masp_transfer_data = MaspTransferData { sources: vec![( TransferSource::Address(source.clone()), token.clone(), - validated_amount, + source_amount, )], - targets: vec![(args.target, token.clone(), validated_amount)], + targets: [ + extra_target, + vec![( + TransferTarget::PaymentAddress(args.target), + token.clone(), + validated_amount, + )], + ] + .concat(), }; + let shielded_transfer = { let mut shielded = context.shielded_mut().await; shielded diff --git a/crates/tests/src/e2e/ibc_tests.rs b/crates/tests/src/e2e/ibc_tests.rs index 3d3a2298bf5..2e5873b4dd6 100644 --- a/crates/tests/src/e2e/ibc_tests.rs +++ b/crates/tests/src/e2e/ibc_tests.rs @@ -146,6 +146,8 @@ fn ibc_transfers() -> Result<()> { None, None, false, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -224,6 +226,8 @@ fn ibc_transfers() -> Result<()> { None, None, false, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -254,6 +258,7 @@ fn ibc_transfers() -> Result<()> { 100, &port_id_namada, &channel_id_namada, + None, )?; transfer_from_cosmos( &test_gaia, @@ -305,6 +310,8 @@ fn ibc_transfers() -> Result<()> { None, None, true, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -324,6 +331,7 @@ fn ibc_transfers() -> Result<()> { 1, &port_id_namada, &channel_id_namada, + None, )?; transfer_from_cosmos( &test_gaia, @@ -358,6 +366,8 @@ fn ibc_transfers() -> Result<()> { None, None, false, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -387,6 +397,8 @@ fn ibc_transfers() -> Result<()> { None, None, false, + None, + None, )?; // wait for the timeout sleep(10); @@ -420,6 +432,8 @@ fn ibc_transfers() -> Result<()> { None, None, true, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -451,6 +465,8 @@ fn ibc_transfers() -> Result<()> { None, None, true, + None, + None, )?; // wait for the timeout sleep(10); @@ -506,6 +522,7 @@ fn ibc_transfers() -> Result<()> { 100, &port_id_namada, &channel_id_namada, + None, )?; transfer_from_cosmos( &test_gaia, @@ -610,6 +627,8 @@ fn ibc_nft_transfers() -> Result<()> { None, None, false, + None, + None, )?; clear_packet(&hermes_dir, &port_id_namada, &channel_id_namada, &test)?; check_balance(&test, &namada_receiver, &ibc_trace_on_namada, 0)?; @@ -623,6 +642,7 @@ fn ibc_nft_transfers() -> Result<()> { 1, &port_id_namada, &channel_id_namada, + None, )?; nft_transfer_from_cosmos( &test_cosmwasm, @@ -672,6 +692,8 @@ fn ibc_nft_transfers() -> Result<()> { None, None, true, + None, + None, )?; clear_packet(&hermes_dir, &port_id_namada, &channel_id_namada, &test)?; check_shielded_balance(&test, AB_VIEWING_KEY, &ibc_trace_on_namada, 0)?; @@ -959,6 +981,7 @@ fn ibc_token_inflation() -> Result<()> { 1, &port_id_namada, &channel_id_namada, + None, )?; transfer_from_cosmos( &test_gaia, @@ -1150,6 +1173,8 @@ fn ibc_rate_limit() -> Result<()> { None, None, false, + None, + None, )?; // Transfer 1 NAM from Namada to Gaia again will fail @@ -1170,6 +1195,8 @@ fn ibc_rate_limit() -> Result<()> { ), None, false, + None, + None, )?; // wait for the next epoch @@ -1194,6 +1221,8 @@ fn ibc_rate_limit() -> Result<()> { None, None, false, + None, + None, )?; // wait for the next epoch @@ -1304,6 +1333,8 @@ fn ibc_unlimited_channel() -> Result<()> { ), None, false, + None, + None, )?; // Proposal on Namada @@ -1362,6 +1393,8 @@ fn ibc_unlimited_channel() -> Result<()> { None, None, false, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -1393,6 +1426,8 @@ fn ibc_unlimited_channel() -> Result<()> { None, None, false, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -1423,6 +1458,8 @@ fn ibc_unlimited_channel() -> Result<()> { None, None, false, + None, + None, )?; // wait for the timeout sleep(10); @@ -1693,6 +1730,8 @@ fn ibc_pfm_happy_flows() -> Result<()> { None, None, false, + None, + None, )?; wait_for_packet_relay( @@ -1990,6 +2029,8 @@ fn ibc_pfm_unhappy_flows() -> Result<()> { None, None, false, + None, + None, )?; wait_for_packet_relay( @@ -2314,6 +2355,7 @@ fn ibc_shielded_recv_middleware_happy_flow() -> Result<()> { 8, &port_id_namada, &channel_id_namada, + None, )?; let masp_receiver = match iter { // Test addresses encoded using `bech32m`... @@ -2353,6 +2395,8 @@ fn ibc_shielded_recv_middleware_happy_flow() -> Result<()> { None, Some(&memo), true, + None, + None, )?; wait_for_packet_relay( &hermes_dir, @@ -2419,6 +2463,7 @@ fn ibc_shielded_recv_middleware_unhappy_flow() -> Result<()> { 8, &port_id_namada, &channel_id_namada, + None, )?; let memo = packet_forward_memo( MASP.to_string().into(), @@ -2445,6 +2490,8 @@ fn ibc_shielded_recv_middleware_unhappy_flow() -> Result<()> { None, Some(&memo), false, + None, + None, )?; wait_for_packet_relay(&hermes_dir, &port_id_gaia, &channel_id_gaia, &test)?; @@ -2703,6 +2750,8 @@ fn try_invalid_transfers( Some(&format!("Invalid IBC denom: {nam_addr}")), None, false, + None, + None, )?; // invalid channel @@ -2720,6 +2769,8 @@ fn try_invalid_transfers( Some("No channel end: port transfer, channel channel-42"), None, false, + None, + None, )?; Ok(()) @@ -2776,6 +2827,8 @@ fn transfer( expected_err: Option<&str>, ibc_memo: Option<&str>, gen_refund_target: bool, + frontend_sus_fee: Option<&str>, + gas_token: Option<&str>, ) -> Result { let rpc = get_actor_rpc(test, Who::Validator(0)); @@ -2797,11 +2850,15 @@ fn transfer( "--port-id", &port_id, "--gas-limit", - "60000", + "70000", "--node", &rpc, ]); + if let Some(token) = gas_token { + tx_args.extend_from_slice(&["--gas-token", token]); + } + if let Some(ibc_memo) = ibc_memo { tx_args.extend_from_slice(&["--ibc-memo", ibc_memo]); } @@ -2843,6 +2900,11 @@ fn transfer( tx_args.push(IBC_REFUND_TARGET_ALIAS); } + if let Some(target) = frontend_sus_fee { + tx_args.push("--test-frontend-sus-fee"); + tx_args.push(target); + } + let mut client = run!(test, Bin::Client, tx_args, Some(300))?; match expected_err { Some(err) => { @@ -3241,7 +3303,7 @@ fn transfer_from_cosmos( let chain_type = CosmosChainType::chain_type(test.net.chain_id.as_str()).unwrap(); let rpc = format!("tcp://127.0.0.1:{}", chain_type.get_rpc_port_number()); - // If the receiver is a pyament address we want to mask it to the more + // If the receiver is a payment address we want to mask it to the more // general MASP internal address to improve on privacy let receiver = match PaymentAddress::from_str(receiver.as_ref()) { Ok(_) => MASP.to_string(), @@ -3259,6 +3321,8 @@ fn transfer_from_cosmos( sender.as_ref(), "--gas-prices", "0.001stake", + "--gas", + "250000", "--node", &rpc, "--keyring-backend", @@ -3464,12 +3528,13 @@ fn gen_ibc_shielding_data( amount: u64, port_id: &PortId, channel_id: &ChannelId, + frontend_sus_fee: Option<&str>, ) -> Result { let rpc = get_actor_rpc(dst_test, Who::Validator(0)); let output_folder = dst_test.test_dir.path().to_string_lossy(); let amount = amount.to_string(); - let args = vec![ + let mut args = vec![ "ibc-gen-shielding", "--output-folder-path", &output_folder, @@ -3486,6 +3551,10 @@ fn gen_ibc_shielding_data( "--node", &rpc, ]; + if let Some(target) = frontend_sus_fee { + args.push("--test-frontend-sus-fee-ibc"); + args.push(target.as_ref()); + } let mut client = run!(dst_test, Bin::Client, args, Some(120))?; let (_unread, matched) = @@ -3807,6 +3876,220 @@ fn nft_transfer_from_cosmos( Ok(()) } +// Verify that MASP frontend fees can be paid also when doing IBC transactions, +// specifically an unshielding originating from Namada and a shielding tx +// originating from a foreign chain +#[test] +fn frontend_sus_fee() -> Result<()> { + let update_genesis = + |mut genesis: templates::All, base_dir: &_| { + genesis.parameters.parameters.epochs_per_year = + epochs_per_year_from_min_duration(1800); + genesis.parameters.ibc_params.default_mint_limit = + Amount::max_signed(); + genesis + .parameters + .ibc_params + .default_per_epoch_throughput_limit = Amount::max_signed(); + setup::set_validators(1, genesis, base_dir, |_| 0, vec![]) + }; + let (ledger, gaia, test, test_gaia) = + run_namada_cosmos(CosmosChainType::Gaia(None), update_genesis)?; + let _bg_ledger = ledger.background(); + let _bg_gaia = gaia.background(); + + let hermes_dir = setup_hermes(&test, &test_gaia)?; + let port_id_namada = FT_PORT_ID.parse().unwrap(); + let port_id_gaia = FT_PORT_ID.parse().unwrap(); + let (channel_id_namada, channel_id_gaia) = create_channel_with_hermes( + &hermes_dir, + &test, + &test_gaia, + &port_id_namada, + &port_id_gaia, + )?; + + // Start relaying + let hermes = run_hermes(&hermes_dir)?; + let _bg_hermes = hermes.background(); + + // Shielding transfer 100 samoleans from Gaia to Namada (this command will + // actually add another extra token in the output as the frontend sus fee) + let shielding_data_path = gen_ibc_shielding_data( + &test, + AA_PAYMENT_ADDRESS, + COSMOS_COIN, + 100, + &port_id_namada, + &channel_id_namada, + Some(AC_PAYMENT_ADDRESS), + )?; + transfer_from_cosmos( + &test_gaia, + COSMOS_USER, + MASP.to_string(), + COSMOS_COIN, + // 10 extra tokens for the frontend sus fee + 110, + &port_id_gaia, + &channel_id_gaia, + Some(Either::Left(shielding_data_path)), + None, + )?; + wait_for_packet_relay( + &hermes_dir, + &port_id_gaia, + &channel_id_gaia, + &test_gaia, + )?; + // Check the token on Namada + let ibc_denom_on_namada = + format!("{port_id_namada}/{channel_id_namada}/{COSMOS_COIN}"); + check_shielded_balance(&test, AA_VIEWING_KEY, &ibc_denom_on_namada, 100)?; + check_shielded_balance(&test, AC_VIEWING_KEY, &ibc_denom_on_namada, 10)?; + check_balance(&test, ESTER, &ibc_denom_on_namada, 0)?; + check_cosmos_balance(&test_gaia, COSMOS_USER, COSMOS_COIN, 890)?; + + // Unshielding transfer 10 samoleans from Namada to Gaia with transparent + // frontend fee + let gaia_receiver = find_cosmos_address(&test_gaia, COSMOS_USER)?; + transfer( + &test, + A_SPENDING_KEY, + &gaia_receiver, + &ibc_denom_on_namada, + // An extra token will be added to this amount as a frontend masp fee + 10, + Some(BERTHA_KEY), + &port_id_namada, + &channel_id_namada, + None, + None, + None, + None, + true, + Some(ESTER), + None, + )?; + wait_for_packet_relay( + &hermes_dir, + &port_id_namada, + &channel_id_namada, + &test, + )?; + check_shielded_balance(&test, AA_VIEWING_KEY, &ibc_denom_on_namada, 89)?; + check_shielded_balance(&test, AC_VIEWING_KEY, &ibc_denom_on_namada, 10)?; + check_balance(&test, ESTER, &ibc_denom_on_namada, 1)?; + check_cosmos_balance(&test_gaia, COSMOS_USER, COSMOS_COIN, 900)?; + + // Unshielding transfer 10 samoleans from Namada to Gaia with shielded + // frontend fee + transfer( + &test, + A_SPENDING_KEY, + &gaia_receiver, + &ibc_denom_on_namada, + // An extra token will be added to this amount as a frontend masp fee + 10, + Some(BERTHA_KEY), + &port_id_namada, + &channel_id_namada, + None, + None, + None, + None, + true, + Some(AC_PAYMENT_ADDRESS), + None, + )?; + wait_for_packet_relay( + &hermes_dir, + &port_id_namada, + &channel_id_namada, + &test, + )?; + check_shielded_balance(&test, AA_VIEWING_KEY, &ibc_denom_on_namada, 78)?; + check_shielded_balance(&test, AC_VIEWING_KEY, &ibc_denom_on_namada, 11)?; + check_balance(&test, ESTER, &ibc_denom_on_namada, 1)?; + check_cosmos_balance(&test_gaia, COSMOS_USER, COSMOS_COIN, 910)?; + + // Shield 50 nams + transfer_on_chain( + &test, + "shield", + ALBERT_KEY, + AA_PAYMENT_ADDRESS, + NAM, + 50, + ALBERT_KEY, + &[], + )?; + check_shielded_balance(&test, AA_VIEWING_KEY, NAM, 50)?; + check_balance(&test, ESTER, NAM, 1000000)?; + // Unshielding transfer 10 samoleans from Namada to Gaia with transparent + // frontend fee. Also pay gas fees via the masp and verify that this amount + // is not subject to frontend masp fees (no recursive fees) + transfer( + &test, + A_SPENDING_KEY, + &gaia_receiver, + &ibc_denom_on_namada, + // An extra token will be added to this amount as a frontend masp fee + 10, + Some(BERTHA_KEY), + &port_id_namada, + &channel_id_namada, + None, + None, + None, + None, + true, + Some(ESTER), + Some(NAM), + )?; + wait_for_packet_relay( + &hermes_dir, + &port_id_namada, + &channel_id_namada, + &test, + )?; + check_shielded_balance(&test, AA_VIEWING_KEY, &ibc_denom_on_namada, 67)?; + check_shielded_balance(&test, AC_VIEWING_KEY, &ibc_denom_on_namada, 11)?; + check_shielded_balance(&test, AC_VIEWING_KEY, NAM, 0)?; + check_balance(&test, ESTER, &ibc_denom_on_namada, 2)?; + check_balance(&test, ESTER, NAM, 1000000)?; + check_cosmos_balance(&test_gaia, COSMOS_USER, COSMOS_COIN, 920)?; + + // Unshielding transfer more samoleans than available with transparent + // frontend fee and shielded gas fees. Verify that client checks prevent + // this + transfer( + &test, + A_SPENDING_KEY, + &gaia_receiver, + &ibc_denom_on_namada, + // An extra 10 tokens will be added to this amount as a frontend masp + // fee + 100, + None, + &port_id_namada, + &channel_id_namada, + None, + None, + Some( + "Failed to construct MASP transaction shielded parts: \ + Insufficient funds: 43 \ + tnam1p5n6vw2v870lnjwu7h0l4humkhlf5d78ay693qmv missing", + ), + None, + true, + Some(ESTER), + None, + )?; + + Ok(()) +} + /// Basic Osmosis test that checks if the chain has been set up correctly. #[test] fn osmosis_basic() -> Result<()> { @@ -3930,6 +4213,8 @@ fn osmosis_xcs() -> Result<()> { None, None, false, + None, + None, )?; // Transfer Samoleans from Gaia transfer_from_cosmos( diff --git a/crates/tests/src/integration/masp.rs b/crates/tests/src/integration/masp.rs index aad55ee8dce..e2e325dbdbc 100644 --- a/crates/tests/src/integration/masp.rs +++ b/crates/tests/src/integration/masp.rs @@ -10173,3 +10173,678 @@ fn history_with_conversions() -> Result<()> { Ok(()) } + +// Test that shielding and unshielding transactions can pay a small fee to a +// transparent address as a form of sustainability fee for frontend providers +#[test] +fn frontend_sus_fee() -> Result<()> { + // This address doesn't matter for tests. But an argument is required. + let validator_one_rpc = "http://127.0.0.1:26567"; + // Download the shielded pool parameters before starting node + let _ = FsShieldedUtils::new(PathBuf::new()); + let (mut node, _services) = setup::initialize_genesis(|mut genesis| { + // Whitelist BTC for gas payment + genesis.parameters.parameters.minimum_gas_price.insert( + "btc".into(), + DenominatedAmount::new(1.into(), token::Denomination(5)), + ); + genesis + })?; + // Wait till epoch boundary + node.next_masp_epoch(); + + // Initialize address of the frontend provider with no balance + let (frontend_alias, _frontend_key) = + make_temp_account(&node, validator_one_rpc, "Frontend", NAM, 0)?; + + for token in [NAM, BTC] { + // Test sus fee when shielding. Send 20 tokens from Albert to PA and 3 + // tokens to a transparent address owned by the frontend provider + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "shield", + "--source", + ALBERT, + "--target", + AA_PAYMENT_ADDRESS, + "--token", + token, + "--amount", + "20", + "--test-frontend-sus-fee", + frontend_alias, + "--signing-keys", + ALBERT_KEY, + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(TX_APPLIED_SUCCESS)); + + // Test sus fee when shielding. Send 10 tokens from Albert to PA and 1 + // token to a shielded address owned by the frontend provider + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "shield", + "--source", + ALBERT, + "--target", + AA_PAYMENT_ADDRESS, + "--token", + token, + "--amount", + "20", + "--test-frontend-sus-fee", + AC_PAYMENT_ADDRESS, + "--signing-keys", + ALBERT_KEY, + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(TX_APPLIED_SUCCESS)); + + // sync the shielded context + run( + &node, + Bin::Client, + vec![ + "shielded-sync", + "--viewing-keys", + AA_VIEWING_KEY, + AC_VIEWING_KEY, + "--node", + validator_one_rpc, + ], + )?; + + // Assert token balance at VK(A) is 40 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AA_VIEWING_KEY, + "--token", + token, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(&format!("{}: 40", token.to_lowercase()))); + + // Assert token balance at the transparent frontend is 2 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + frontend_alias, + "--token", + token, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(&format!("{}: 2", token.to_lowercase()))); + + // Assert token balance at the shielded frontend is 2 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AC_VIEWING_KEY, + "--token", + token, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(&format!("{}: 2", token.to_lowercase()))); + } + + for token in [NAM, BTC] { + // Test sus fee when unshielding. Send 9 tokens from PA to Albert and + // 0.9 tokens to a transparent address owned by the frontend provider + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "unshield", + "--source", + AA_VIEWING_KEY, + "--target", + ALBERT, + "--token", + token, + "--amount", + "9", + "--test-frontend-sus-fee", + frontend_alias, + "--signing-keys", + ALBERT_KEY, + "--gas-limit", + "60000", + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(TX_APPLIED_SUCCESS)); + + // Test sus fee when unshielding. Send 8 tokens from PA to Albert and + // 0.8 tokens to a shielded address owned by the frontend provider. Also + // pay gas fees via the masp in this case and check that the frontend + // fees does not account for the fee unshielding amount (no recursive + // fees) + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "unshield", + "--source", + AA_VIEWING_KEY, + "--target", + ALBERT, + "--token", + token, + "--amount", + "8", + "--test-frontend-sus-fee", + AC_PAYMENT_ADDRESS, + "--gas-token", + token, + "--gas-limit", + "70000", + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(TX_APPLIED_SUCCESS)); + + // sync the shielded context + run( + &node, + Bin::Client, + vec![ + "shielded-sync", + "--viewing-keys", + AA_VIEWING_KEY, + AC_VIEWING_KEY, + "--node", + validator_one_rpc, + ], + )?; + + // Assert token balance at VK(A) is 20.6 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AA_VIEWING_KEY, + "--token", + token, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(&format!("{}: 20.6", token.to_lowercase()))); + + // Assert token balance at the transparent frontend is 2.9 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + frontend_alias, + "--token", + token, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(&format!("{}: 2.9", token.to_lowercase()))); + + // Assert token balance at the shielded frontend is 2.8 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AC_VIEWING_KEY, + "--token", + token, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(&format!("{}: 2.8", token.to_lowercase()))); + } + + // Test sus fee when unshielding. Send 10 BTCs from PA to Albert and + // 1 BTC to a shielded address owned by the frontend provider. Also pay gas + // fees via the masp with a different token and check that the frontend fees + // does not account for the fee unshielding amount (no recursive fees) + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "unshield", + "--source", + AA_VIEWING_KEY, + "--target", + ALBERT, + "--token", + BTC, + "--amount", + "10", + "--test-frontend-sus-fee", + AC_PAYMENT_ADDRESS, + "--gas-token", + NAM, + "--gas-limit", + "100000", + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(TX_APPLIED_SUCCESS)); + + // sync the shielded context + run( + &node, + Bin::Client, + vec![ + "shielded-sync", + "--viewing-keys", + AA_VIEWING_KEY, + AC_VIEWING_KEY, + "--node", + validator_one_rpc, + ], + )?; + + // Assert BTC balance at VK(A) is 9.6 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AA_VIEWING_KEY, + "--token", + BTC, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains("btc: 9.6")); + + // Assert NAM balance at VK(A) is 19.6 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AA_VIEWING_KEY, + "--token", + NAM, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains("nam: 19.6")); + + // Assert BTC balance at the shielded frontend is 3.8 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AC_VIEWING_KEY, + "--token", + BTC, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains("btc: 3.8")); + + // Assert NAM balance at the shielded frontend is 2.8 + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AC_VIEWING_KEY, + "--token", + NAM, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains("nam: 2.8")); + + Ok(()) +} + +// Check that the clients performs balance checks correctly when adding a masp +// frontend sus fee +#[test] +fn frontend_sus_fee_client_checks() -> Result<()> { + // This address doesn't matter for tests. But an argument is required. + let validator_one_rpc = "http://127.0.0.1:26567"; + // Download the shielded pool parameters before starting node + let _ = FsShieldedUtils::new(PathBuf::new()); + let (mut node, _services) = setup::setup()?; + // Wait till epoch boundary + node.next_masp_epoch(); + + // Initialize source address + let (source, _source_key) = + make_temp_account(&node, validator_one_rpc, "Source", NAM, 100)?; + + // Test a shielding tx where the amount and the transparent masp fee amount + // together exceed the source balance + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "shield", + "--source", + source, + "--target", + AA_PAYMENT_ADDRESS, + "--token", + NAM, + "--amount", + "100", + "--test-frontend-sus-fee", + ALBERT, + "--signing-keys", + ALBERT_KEY, + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_err()); + assert!(captured.contains( + "is lower than the amount to be transferred. Amount to transfer is \ + 110.000000 and the balance is 100.000000." + )); + + // Test a shielding tx where the amount and the shielded masp fee amount + // together exceed the source balance + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "shield", + "--source", + source, + "--target", + AA_PAYMENT_ADDRESS, + "--token", + NAM, + "--amount", + "100", + "--test-frontend-sus-fee", + AC_PAYMENT_ADDRESS, + "--signing-keys", + ALBERT_KEY, + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_err()); + assert!(captured.contains( + "is lower than the amount to be transferred. Amount to transfer is \ + 110.000000 and the balance is 100.000000." + )); + + // Test a shielding tx where the amount, the gas fee amount and the shielded + // masp fee amount together exceed the source balance + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "shield", + "--source", + source, + "--target", + AA_PAYMENT_ADDRESS, + "--token", + NAM, + "--amount", + "100", + "--test-frontend-sus-fee", + AC_PAYMENT_ADDRESS, + "--signing-keys", + source, + "--gas-limit", + "100000", + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_err()); + assert!(captured.contains( + "is lower than the amount to be transferred. Amount to transfer is \ + 110.000000 and the balance is 99.000000." + )); + + // Shield some tokens to the source + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "shield", + "--source", + ALBERT, + "--target", + AA_PAYMENT_ADDRESS, + "--token", + NAM, + "--amount", + "100", + "--signing-keys", + ALBERT_KEY, + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains(TX_APPLIED_SUCCESS)); + + // sync the shielded context + run( + &node, + Bin::Client, + vec![ + "shielded-sync", + "--viewing-keys", + AA_VIEWING_KEY, + "--node", + validator_one_rpc, + ], + )?; + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + vec![ + "balance", + "--owner", + AA_VIEWING_KEY, + "--token", + NAM, + "--node", + validator_one_rpc, + ], + ) + }); + assert!(captured.result.is_ok()); + assert!(captured.contains("nam: 100")); + + // Test an unshielding tx where the amount and the transparent masp fee + // amount together exceed the source balance + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "unshield", + "--source", + AA_VIEWING_KEY, + "--target", + source, + "--token", + NAM, + "--amount", + "100", + "--test-frontend-sus-fee", + ALBERT, + "--signing-keys", + ALBERT_KEY, + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_err()); + assert!(captured.contains( + "Failed to construct MASP transaction shielded parts: Insufficient \ + funds: 10 tnam1q9kn74xfzytqkqyycfrhycr8ajam8ny935cge0z5 missing" + )); + + // Test an unshielding tx where the amount and the shielded masp fee amount + // together exceed the source balance + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "unshield", + "--source", + AA_VIEWING_KEY, + "--target", + source, + "--token", + NAM, + "--amount", + "100", + "--test-frontend-sus-fee", + AC_PAYMENT_ADDRESS, + "--signing-keys", + ALBERT_KEY, + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_err()); + assert!(captured.contains( + "Failed to construct MASP transaction shielded parts: Insufficient \ + funds: 10 tnam1q9kn74xfzytqkqyycfrhycr8ajam8ny935cge0z5 missing" + )); + + // Test an unshielding tx where the amount, the gas fee unshielding amount + // and the shielded masp fee amount together exceed the source balance + let captured = CapturedOutput::of(|| { + run( + &node, + Bin::Client, + apply_use_device(vec![ + "unshield", + "--source", + AA_VIEWING_KEY, + "--target", + source, + "--token", + NAM, + "--amount", + "100", + "--test-frontend-sus-fee", + AC_PAYMENT_ADDRESS, + "--gas-limit", + "100000", + "--node", + validator_one_rpc, + ]), + ) + }); + assert!(captured.result.is_err()); + assert!(captured.contains( + "Failed to construct MASP transaction shielded parts: Insufficient \ + funds: 11 tnam1q9kn74xfzytqkqyycfrhycr8ajam8ny935cge0z5 missing" + )); + + Ok(()) +} diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index 9b33c689d89..e8c971b4015 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -4537,9 +4537,9 @@ dependencies = [ [[package]] name = "nam-sparse-merkle-tree" -version = "0.3.2-nam.0" +version = "0.3.3-nam.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae108a0f6aabf789e34d6d447c1184608d1c70c087f957b720042226a47ab63" +checksum = "09548cb2907afb9ee1047b7bbe9601e25b73b69229fca50d49badd97cc190cfb" dependencies = [ "borsh", "cfg-if", diff --git a/wasm_for_tests/Cargo.lock b/wasm_for_tests/Cargo.lock index 6574b06d4a6..6efbd8887cb 100644 --- a/wasm_for_tests/Cargo.lock +++ b/wasm_for_tests/Cargo.lock @@ -2618,9 +2618,9 @@ dependencies = [ [[package]] name = "nam-sparse-merkle-tree" -version = "0.3.2-nam.0" +version = "0.3.3-nam.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae108a0f6aabf789e34d6d447c1184608d1c70c087f957b720042226a47ab63" +checksum = "09548cb2907afb9ee1047b7bbe9601e25b73b69229fca50d49badd97cc190cfb" dependencies = [ "borsh", "cfg-if",