From d50f2745f8aa0c0e5a537664e5bc71484fb6c76e Mon Sep 17 00:00:00 2001 From: Jon Cinque Date: Fri, 11 Nov 2022 19:16:51 +0100 Subject: [PATCH] token-cli: Support transfer fee extension (#3792) * token-cli: Add transfer fee to create-token * Add `expected-fee` arg to transfer * Add withdraw-withheld-tokens implementation * Add optional `Harvest` call while closing account * Add `set-transfer-fee` command * Fix rebase issues --- token/cli/src/main.rs | 625 ++++++++++++++++++++++++++++++++++++-- token/client/src/token.rs | 16 +- 2 files changed, 622 insertions(+), 19 deletions(-) diff --git a/token/cli/src/main.rs b/token/cli/src/main.rs index 83ca6d7e..2c7ec844 100644 --- a/token/cli/src/main.rs +++ b/token/cli/src/main.rs @@ -37,9 +37,13 @@ use solana_sdk::{ use spl_associated_token_account::get_associated_token_address_with_program_id; use spl_token_2022::{ extension::{ - cpi_guard::CpiGuard, default_account_state::DefaultAccountState, - interest_bearing_mint::InterestBearingConfig, memo_transfer::MemoTransfer, - mint_close_authority::MintCloseAuthority, ExtensionType, StateWithExtensionsOwned, + cpi_guard::CpiGuard, + default_account_state::DefaultAccountState, + interest_bearing_mint::InterestBearingConfig, + memo_transfer::MemoTransfer, + mint_close_authority::MintCloseAuthority, + transfer_fee::{TransferFeeAmount, TransferFeeConfig}, + ExtensionType, StateWithExtensionsOwned, }, instruction::*, state::{Account, AccountState, Mint}, @@ -135,6 +139,8 @@ pub enum CommandName { EnableCpiGuard, DisableCpiGuard, UpdateDefaultAccountState, + WithdrawWithheldTokens, + SetTransferFee, } impl fmt::Display for CommandName { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -253,6 +259,10 @@ where } pub(crate) type Error = Box; +fn print_error_and_exit(e: E) -> T { + eprintln!("error: {}", e); + exit(1) +} type BulkSigners = Vec>; pub(crate) type CommandResult = Result; @@ -269,11 +279,8 @@ fn get_signer( wallet_manager: &mut Option>, ) -> Option<(Arc, Pubkey)> { matches.value_of(keypair_name).map(|path| { - let signer = - signer_from_path(matches, path, keypair_name, wallet_manager).unwrap_or_else(|e| { - eprintln!("error: {}", e); - exit(1); - }); + let signer = signer_from_path(matches, path, keypair_name, wallet_manager) + .unwrap_or_else(print_error_and_exit); let signer_pubkey = signer.pubkey(); (Arc::from(signer), signer_pubkey) }) @@ -391,6 +398,7 @@ async fn command_create_token( memo: Option, rate_bps: Option, default_account_state: Option, + transfer_fee: Option<(u16, u64)>, bulk_signers: Vec>, ) -> CommandResult { println_display( @@ -432,6 +440,15 @@ async fn command_create_token( extensions.push(ExtensionInitializationParams::DefaultAccountState { state }) } + if let Some((transfer_fee_basis_points, maximum_fee)) = transfer_fee { + extensions.push(ExtensionInitializationParams::TransferFeeConfig { + transfer_fee_config_authority: Some(authority), + withdraw_withheld_authority: Some(authority), + transfer_fee_basis_points, + maximum_fee, + }); + } + if let Some(text) = memo { token.with_memo(text, vec![config.default_signer()?.pubkey()]); } @@ -520,6 +537,83 @@ async fn command_set_interest_rate( }) } +async fn command_set_transfer_fee( + config: &Config<'_>, + token_pubkey: Pubkey, + transfer_fee_authority: Pubkey, + transfer_fee_basis_points: u16, + maximum_fee: f64, + mint_decimals: Option, + bulk_signers: Vec>, +) -> CommandResult { + let decimals = if !config.sign_only { + let mint_account = config.get_account_checked(&token_pubkey).await?; + + let mint_state = StateWithExtensionsOwned::::unpack(mint_account.data) + .map_err(|_| format!("Could not deserialize token mint {}", token_pubkey))?; + + if mint_decimals.is_some() && mint_decimals != Some(mint_state.base.decimals) { + return Err(format!( + "Decimals {} was provided, but actual value is {}", + mint_decimals.unwrap(), + mint_state.base.decimals + ) + .into()); + } + + if let Ok(transfer_fee_config) = mint_state.get_extension::() { + let mint_fee_authority_pubkey = + Option::::from(transfer_fee_config.transfer_fee_config_authority); + + if mint_fee_authority_pubkey != Some(transfer_fee_authority) { + return Err(format!( + "Mint {} has transfer fee authority {}, but {} was provided", + token_pubkey, + mint_fee_authority_pubkey + .map(|pubkey| pubkey.to_string()) + .unwrap_or_else(|| "disabled".to_string()), + transfer_fee_authority + ) + .into()); + } + } else { + return Err(format!("Mint {} does not have a transfer fee", token_pubkey).into()); + } + mint_state.base.decimals + } else { + mint_decimals.unwrap() + }; + + println_display( + config, + format!( + "Setting transfer fee for {} to {} bps, {} maximum", + token_pubkey, transfer_fee_basis_points, maximum_fee + ), + ); + + let token = token_client_from_config(config, &token_pubkey, Some(decimals))?; + let maximum_fee = spl_token::ui_amount_to_amount(maximum_fee, decimals); + let res = token + .set_transfer_fee( + &transfer_fee_authority, + transfer_fee_basis_points, + maximum_fee, + &bulk_signers, + ) + .await?; + + let tx_return = finish_tx(config, &res, false).await?; + Ok(match tx_return { + TransactionReturnData::CliSignature(signature) => { + config.output_format.formatted_string(&signature) + } + TransactionReturnData::CliSignOnlyData(sign_only_data) => { + config.output_format.formatted_string(&sign_only_data) + } + }) +} + async fn command_create_account( config: &Config<'_>, token_pubkey: Pubkey, @@ -805,6 +899,7 @@ async fn command_transfer( mint_decimals: Option, recipient_is_ata_owner: bool, use_unchecked_instruction: bool, + ui_fee: Option, memo: Option, bulk_signers: BulkSigners, no_wait: bool, @@ -880,6 +975,9 @@ async fn command_transfer( maybe_transfer_balance.unwrap() }; + let maybe_fee = + ui_fee.map(|ui_amount| spl_token::ui_amount_to_amount(ui_amount, mint_info.decimals)); + // determine whether recipient is a token account or an expected owner of one let recipient_is_token_account = if !config.sign_only { // in online mode we can fetch it and see @@ -1037,6 +1135,18 @@ async fn command_transfer( &recipient_owner, &sender_owner, transfer_balance, + maybe_fee, + &bulk_signers, + ) + .await? + } else if let Some(fee) = maybe_fee { + token + .transfer_with_fee( + &sender, + &recipient_token_account, + &sender_owner, + transfer_balance, + fee, &bulk_signers, ) .await? @@ -1448,7 +1558,8 @@ async fn command_close( recipient: Pubkey, bulk_signers: BulkSigners, ) -> CommandResult { - let mint_pubkey = if !config.sign_only { + let mut results = vec![]; + let token = if !config.sign_only { let source_account = config.get_account_checked(&account).await?; let source_state = StateWithExtensionsOwned::::unpack(source_account.data) @@ -1463,26 +1574,42 @@ async fn command_close( .into()); } - source_state.base.mint + let token = token_client_from_config(config, &source_state.base.mint, None)?; + if let Ok(extension) = source_state.get_extension::() { + if u64::from(extension.withheld_amount) != 0 { + let res = token.harvest_withheld_tokens_to_mint(&[&account]).await?; + let tx_return = finish_tx(config, &res, false).await?; + results.push(match tx_return { + TransactionReturnData::CliSignature(signature) => { + config.output_format.formatted_string(&signature) + } + TransactionReturnData::CliSignOnlyData(sign_only_data) => { + config.output_format.formatted_string(&sign_only_data) + } + }); + } + } + + token } else { // default is safe here because close doesnt use it - Pubkey::default() + token_client_from_config(config, &Pubkey::default(), None)? }; - let token = token_client_from_config(config, &mint_pubkey, None)?; let res = token .close_account(&account, &recipient, &close_authority, &bulk_signers) .await?; let tx_return = finish_tx(config, &res, false).await?; - Ok(match tx_return { + results.push(match tx_return { TransactionReturnData::CliSignature(signature) => { config.output_format.formatted_string(&signature) } TransactionReturnData::CliSignOnlyData(sign_only_data) => { config.output_format.formatted_string(&sign_only_data) } - }) + }); + Ok(results.join("")) } async fn command_close_mint( @@ -2069,6 +2196,79 @@ async fn command_update_default_account_state( }) } +async fn command_withdraw_withheld_tokens( + config: &Config<'_>, + destination_token_account: Pubkey, + source_token_accounts: Vec, + authority: Pubkey, + include_mint: bool, + bulk_signers: BulkSigners, +) -> CommandResult { + if config.sign_only { + panic!("Config can not be sign-only for withdrawing withheld tokens."); + } + let destination_account = config + .get_account_checked(&destination_token_account) + .await?; + let destination_state = StateWithExtensionsOwned::::unpack(destination_account.data) + .map_err(|_| { + format!( + "Could not deserialize token account {}", + destination_token_account + ) + })?; + let token_pubkey = destination_state.base.mint; + destination_state + .get_extension::() + .map_err(|_| format!("Token mint {} has no transfer fee configured", token_pubkey))?; + + let token = token_client_from_config(config, &token_pubkey, None)?; + let mut results = vec![]; + if include_mint { + let res = token + .withdraw_withheld_tokens_from_mint( + &destination_token_account, + &authority, + &bulk_signers, + ) + .await; + let tx_return = finish_tx(config, &res?, false).await?; + results.push(match tx_return { + TransactionReturnData::CliSignature(signature) => { + config.output_format.formatted_string(&signature) + } + TransactionReturnData::CliSignOnlyData(sign_only_data) => { + config.output_format.formatted_string(&sign_only_data) + } + }); + } + + let source_refs = source_token_accounts.iter().collect::>(); + // this can be tweaked better, but keep it simple for now + const MAX_WITHDRAWAL_ACCOUNTS: usize = 25; + for sources in source_refs.chunks(MAX_WITHDRAWAL_ACCOUNTS) { + let res = token + .withdraw_withheld_tokens_from_accounts( + &destination_token_account, + &authority, + sources, + &bulk_signers, + ) + .await; + let tx_return = finish_tx(config, &res?, false).await?; + results.push(match tx_return { + TransactionReturnData::CliSignature(signature) => { + config.output_format.formatted_string(&signature) + } + TransactionReturnData::CliSignOnlyData(sign_only_data) => { + config.output_format.formatted_string(&sign_only_data) + } + }); + } + + Ok(results.join("")) +} + struct SignOnlyNeedsFullMintSpec {} impl offline::ArgsConfig for SignOnlyNeedsFullMintSpec { fn sign_only_arg<'a, 'b>(&self, arg: Arg<'a, 'b>) -> Arg<'a, 'b> { @@ -2261,6 +2461,17 @@ fn app<'a, 'b>( This behavior is not the same as the default, which makes it \ impossible to specify a default account state in the future."), ) + .arg( + Arg::with_name("transfer_fee") + .long("transfer-fee") + .value_names(&["FEE_IN_BASIS_POINTS", "MAXIMUM_FEE"]) + .takes_value(true) + .number_of_values(2) + .help( + "Add a transfer fee to the mint. \ + The mint authority can set the fee and withdraw collected fees.", + ), + ) .nonce_args(true) .arg(memo_arg()) ) @@ -2516,6 +2727,14 @@ fn app<'a, 'b>( .requires("sign_only") .help("In sign-only mode, specifies that the recipient is the owner of the associated token account rather than an actual token account"), ) + .arg( + Arg::with_name("expected_fee") + .long("expected-fee") + .validator(is_amount) + .value_name("TOKEN_AMOUNT") + .takes_value(true) + .help("Expected fee amount collected during the transfer"), + ) .arg(multisig_signer_arg()) .arg(mint_decimals_arg()) .nonce_args(true) @@ -3178,6 +3397,88 @@ fn app<'a, 'b>( .nonce_args(true) .offline_args(), ) + .subcommand( + SubCommand::with_name(CommandName::WithdrawWithheldTokens.into()) + .about("Withdraw withheld transfer fee tokens from mint and / or account(s)") + .arg( + Arg::with_name("account") + .validator(is_valid_pubkey) + .value_name("TOKEN_ACCOUNT_ADDRESS") + .takes_value(true) + .index(1) + .required(true) + .help("The address of the token account to receive withdrawn tokens"), + ) + .arg( + Arg::with_name("source") + .validator(is_valid_pubkey) + .value_name("ACCOUNT_ADDRESS") + .takes_value(true) + .multiple(true) + .min_values(0u64) + .help("The token accounts to withdraw from") + ) + .arg( + Arg::with_name("include_mint") + .long("include-mint") + .takes_value(false) + .help("Also withdraw withheld tokens from the mint"), + ) + .arg( + Arg::with_name("withdraw_withheld_authority") + .long("withdraw-withheld-authority") + .alias("owner") + .value_name("KEYPAIR") + .validator(is_valid_signer) + .takes_value(true) + .help( + "Specify the withdraw withheld authority keypair. \ + This may be a keypair file or the ASK keyword. \ + Defaults to the client keypair." + ), + ) + .arg(owner_address_arg()) + .arg(multisig_signer_arg()) + ) + .subcommand( + SubCommand::with_name(CommandName::SetTransferFee.into()) + .about("Set the transfer fee for a token with a configured transfer fee") + .arg( + Arg::with_name("token") + .validator(is_valid_pubkey) + .value_name("TOKEN_MINT_ADDRESS") + .takes_value(true) + .required(true) + .help("The interest-bearing token address"), + ) + .arg( + Arg::with_name("transfer_fee_basis_points") + .value_name("FEE_IN_BASIS_POINTS") + .takes_value(true) + .required(true) + .help("The new transfer fee in basis points"), + ) + .arg( + Arg::with_name("maximum_fee") + .value_name("TOKEN_AMOUNT") + .validator(is_amount) + .takes_value(true) + .required(true) + .help("The new maximum transfer fee in UI amount"), + ) + .arg( + Arg::with_name("transfer_fee_authority") + .long("transfer-fee-authority") + .validator(is_valid_signer) + .value_name("SIGNER") + .takes_value(true) + .help( + "Specify the rate authority keypair. \ + Defaults to the client keypair address." + ) + ) + .arg(mint_decimals_arg()) + ) } #[tokio::main] @@ -3239,6 +3540,19 @@ async fn process_command<'a>( let memo = value_t!(arg_matches, "memo", String).ok(); let rate_bps = value_t!(arg_matches, "interest_rate", i16).ok(); + let transfer_fee = arg_matches.values_of("transfer_fee").map(|mut v| { + ( + v.next() + .unwrap() + .parse::() + .unwrap_or_else(print_error_and_exit), + v.next() + .unwrap() + .parse::() + .unwrap_or_else(print_error_and_exit), + ) + }); + let (token_signer, token) = get_signer(arg_matches, "token_keypair", &mut wallet_manager) .unwrap_or_else(new_throwaway_signer); @@ -3266,6 +3580,7 @@ async fn process_command<'a>( memo, rate_bps, default_account_state, + transfer_fee, bulk_signers, ) .await @@ -3318,10 +3633,7 @@ async fn process_command<'a>( let minimum_signers = value_of::(arg_matches, "minimum_signers").unwrap(); let multisig_members = pubkeys_of_multiple_signers(arg_matches, "multisig_member", &mut wallet_manager) - .unwrap_or_else(|e| { - eprintln!("error: {}", e); - exit(1); - }) + .unwrap_or_else(print_error_and_exit) .unwrap(); if minimum_signers as usize > multisig_members.len() { eprintln!( @@ -3399,6 +3711,7 @@ async fn process_command<'a>( let recipient_is_ata_owner = arg_matches.is_present("recipient_is_ata_owner"); let use_unchecked_instruction = arg_matches.is_present("use_unchecked_instruction"); + let expected_fee = value_of::(arg_matches, "expected_fee"); let memo = value_t!(arg_matches, "memo", String).ok(); command_transfer( @@ -3413,6 +3726,7 @@ async fn process_command<'a>( mint_decimals, recipient_is_ata_owner, use_unchecked_instruction, + expected_fee, memo, bulk_signers, arg_matches.is_present("no_wait"), @@ -3817,6 +4131,60 @@ async fn process_command<'a>( ) .await } + (CommandName::WithdrawWithheldTokens, arg_matches) => { + let (authority_signer, authority) = config.signer_or_default( + arg_matches, + "withdraw_withheld_authority", + &mut wallet_manager, + ); + if !bulk_signers.contains(&authority_signer) { + bulk_signers.push(authority_signer); + } + // Since destination is required it will always be present + let destination_token_account = + pubkey_of_signer(arg_matches, "account", &mut wallet_manager) + .unwrap() + .unwrap(); + let include_mint = arg_matches.is_present("include_mint"); + let source_accounts = arg_matches + .values_of("source") + .unwrap_or_default() + .into_iter() + .map(|s| Pubkey::from_str(s).unwrap_or_else(print_error_and_exit)) + .collect::>(); + command_withdraw_withheld_tokens( + config, + destination_token_account, + source_accounts, + authority, + include_mint, + bulk_signers, + ) + .await + } + (CommandName::SetTransferFee, arg_matches) => { + let token_pubkey = pubkey_of_signer(arg_matches, "token", &mut wallet_manager) + .unwrap() + .unwrap(); + let transfer_fee_basis_points = + value_t_or_exit!(arg_matches, "transfer_fee_basis_points", u16); + let maximum_fee = value_t_or_exit!(arg_matches, "maximum_fee", f64); + let (transfer_fee_authority_signer, transfer_fee_authority_pubkey) = config + .signer_or_default(arg_matches, "transfer_fee_authority", &mut wallet_manager); + let mint_decimals = value_of::(arg_matches, MINT_DECIMALS_ARG.name); + let bulk_signers = vec![transfer_fee_authority_signer]; + + command_set_transfer_fee( + config, + token_pubkey, + transfer_fee_authority_pubkey, + transfer_fee_basis_points, + maximum_fee, + mint_decimals, + bulk_signers, + ) + .await + } } } @@ -4008,6 +4376,7 @@ mod tests { None, None, None, + None, bulk_signers, ) .await @@ -4036,6 +4405,7 @@ mod tests { None, Some(rate_bps), None, + None, bulk_signers, ) .await @@ -5284,6 +5654,7 @@ mod tests { None, None, None, + None, bulk_signers, ) .await @@ -5589,6 +5960,7 @@ mod tests { None, None, None, + None, bulk_signers, ) .await @@ -5642,6 +6014,7 @@ mod tests { None, None, Some(AccountState::Frozen), + None, bulk_signers, ) .await @@ -5682,4 +6055,220 @@ mod tests { let account = StateWithExtensionsOwned::::unpack(token_account.data).unwrap(); assert_eq!(account.base.state, AccountState::Initialized); } + + #[tokio::test] + #[serial] + async fn transfer_fee() { + let (test_validator, payer) = new_validator_for_test().await; + let config = + test_config_with_default_signer(&test_validator, &payer, &spl_token_2022::id()); + + let token_keypair = Keypair::new(); + let token_pubkey = token_keypair.pubkey(); + let bulk_signers: Vec> = + vec![Arc::new(clone_keypair(&payer)), Arc::new(token_keypair)]; + let transfer_fee_basis_points = 100; + let maximum_fee = 2_000_000; + + command_create_token( + &config, + TEST_DECIMALS, + token_pubkey, + payer.pubkey(), + false, + false, + false, + None, + None, + None, + Some((transfer_fee_basis_points, maximum_fee)), + bulk_signers, + ) + .await + .unwrap(); + + let account = config.rpc_client.get_account(&token_pubkey).await.unwrap(); + let test_mint = StateWithExtensionsOwned::::unpack(account.data).unwrap(); + let extension = test_mint.get_extension::().unwrap(); + assert_eq!( + u16::from(extension.older_transfer_fee.transfer_fee_basis_points), + transfer_fee_basis_points + ); + assert_eq!( + u64::from(extension.older_transfer_fee.maximum_fee), + maximum_fee + ); + assert_eq!( + u16::from(extension.newer_transfer_fee.transfer_fee_basis_points), + transfer_fee_basis_points + ); + assert_eq!( + u64::from(extension.newer_transfer_fee.maximum_fee), + maximum_fee + ); + + let total_amount = 1000.0; + let transfer_amount = 100.0; + let token_account = create_associated_account(&config, &payer, token_pubkey).await; + let source_account = create_auxiliary_account(&config, &payer, token_pubkey).await; + mint_tokens(&config, &payer, token_pubkey, total_amount, source_account).await; + + // withdraw from account directly + process_test_command( + &config, + &payer, + &[ + "spl-token", + CommandName::Transfer.into(), + "--from", + &source_account.to_string(), + &token_pubkey.to_string(), + &transfer_amount.to_string(), + &token_account.to_string(), + "--expected-fee", + "1", + ], + ) + .await + .unwrap(); + + let account = config.rpc_client.get_account(&token_account).await.unwrap(); + let account_state = StateWithExtensionsOwned::::unpack(account.data).unwrap(); + let extension = account_state.get_extension::().unwrap(); + let withheld_amount = + spl_token::amount_to_ui_amount(u64::from(extension.withheld_amount), TEST_DECIMALS); + assert_eq!(withheld_amount, 1.0); + + process_test_command( + &config, + &payer, + &[ + "spl-token", + CommandName::WithdrawWithheldTokens.into(), + &token_account.to_string(), + &token_account.to_string(), + ], + ) + .await + .unwrap(); + + let account = config.rpc_client.get_account(&token_account).await.unwrap(); + let account_state = StateWithExtensionsOwned::::unpack(account.data).unwrap(); + let extension = account_state.get_extension::().unwrap(); + assert_eq!(u64::from(extension.withheld_amount), 0); + + // withdraw from mint after account closure + // gather fees + process_test_command( + &config, + &payer, + &[ + "spl-token", + CommandName::Transfer.into(), + "--from", + &source_account.to_string(), + &token_pubkey.to_string(), + &(total_amount - transfer_amount).to_string(), + &token_account.to_string(), + "--expected-fee", + "9", + ], + ) + .await + .unwrap(); + + // burn tokens + let account = config.rpc_client.get_account(&token_account).await.unwrap(); + let account_state = StateWithExtensionsOwned::::unpack(account.data).unwrap(); + let burn_amount = spl_token::amount_to_ui_amount(account_state.base.amount, TEST_DECIMALS); + process_test_command( + &config, + &payer, + &[ + "spl-token", + CommandName::Burn.into(), + &token_account.to_string(), + &burn_amount.to_string(), + ], + ) + .await + .unwrap(); + + let account = config.rpc_client.get_account(&token_account).await.unwrap(); + let account_state = StateWithExtensionsOwned::::unpack(account.data).unwrap(); + let extension = account_state.get_extension::().unwrap(); + let withheld_amount = + spl_token::amount_to_ui_amount(u64::from(extension.withheld_amount), TEST_DECIMALS); + assert_eq!(withheld_amount, 9.0); + + process_test_command( + &config, + &payer, + &[ + "spl-token", + CommandName::Close.into(), + "--address", + &token_account.to_string(), + "--recipient", + &payer.pubkey().to_string(), + ], + ) + .await + .unwrap(); + + let mint = config.rpc_client.get_account(&token_pubkey).await.unwrap(); + let mint_state = StateWithExtensionsOwned::::unpack(mint.data).unwrap(); + let extension = mint_state.get_extension::().unwrap(); + let withheld_amount = + spl_token::amount_to_ui_amount(u64::from(extension.withheld_amount), TEST_DECIMALS); + assert_eq!(withheld_amount, 9.0); + + process_test_command( + &config, + &payer, + &[ + "spl-token", + CommandName::WithdrawWithheldTokens.into(), + &source_account.to_string(), + "--include-mint", + ], + ) + .await + .unwrap(); + + let mint = config.rpc_client.get_account(&token_pubkey).await.unwrap(); + let mint_state = StateWithExtensionsOwned::::unpack(mint.data).unwrap(); + let extension = mint_state.get_extension::().unwrap(); + assert_eq!(u64::from(extension.withheld_amount), 0); + + // set the transfer fee + let new_transfer_fee_basis_points = 800; + let new_maximum_fee = 5_000_000.0; + process_test_command( + &config, + &payer, + &[ + "spl-token", + CommandName::SetTransferFee.into(), + &token_pubkey.to_string(), + &new_transfer_fee_basis_points.to_string(), + &new_maximum_fee.to_string(), + ], + ) + .await + .unwrap(); + + let mint = config.rpc_client.get_account(&token_pubkey).await.unwrap(); + let mint_state = StateWithExtensionsOwned::::unpack(mint.data).unwrap(); + let extension = mint_state.get_extension::().unwrap(); + assert_eq!( + u16::from(extension.newer_transfer_fee.transfer_fee_basis_points), + new_transfer_fee_basis_points + ); + let new_maximum_fee = spl_token::ui_amount_to_amount(new_maximum_fee, TEST_DECIMALS); + assert_eq!( + u64::from(extension.newer_transfer_fee.maximum_fee), + new_maximum_fee + ); + } } diff --git a/token/client/src/token.rs b/token/client/src/token.rs index 2bd44306..2d874831 100644 --- a/token/client/src/token.rs +++ b/token/client/src/token.rs @@ -790,6 +790,7 @@ where destination_owner: &Pubkey, authority: &Pubkey, amount: u64, + fee: Option, signing_keypairs: &S, ) -> TokenResult { let signing_pubkeys = signing_keypairs.pubkeys(); @@ -808,7 +809,20 @@ where )), ]; - if let Some(decimals) = self.decimals { + if let Some(fee) = fee { + let decimals = self.decimals.ok_or(TokenError::MissingDecimals)?; + instructions.push(transfer_fee::instruction::transfer_checked_with_fee( + &self.program_id, + source, + &self.pubkey, + destination, + authority, + &multisig_signers, + amount, + decimals, + fee, + )?); + } else if let Some(decimals) = self.decimals { instructions.push(instruction::transfer_checked( &self.program_id, source,