From b59a5a6d9ec7eaa9b4bd9eac6e35e7b466ad1423 Mon Sep 17 00:00:00 2001 From: "Peter A." Date: Mon, 10 Aug 2026 20:10:11 +0200 Subject: [PATCH] feat(cli): support recovering nested associated token accounts --- clients/cli/src/clap_app.rs | 29 ++++++++ clients/cli/src/command.rs | 77 +++++++++++++++++++- clients/cli/tests/command.rs | 133 +++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) diff --git a/clients/cli/src/clap_app.rs b/clients/cli/src/clap_app.rs index 24f789d7d..470c9539e 100644 --- a/clients/cli/src/clap_app.rs +++ b/clients/cli/src/clap_app.rs @@ -141,6 +141,7 @@ pub enum CommandName { AccountInfo, MultisigInfo, Display, + RecoverNested, Gc, SyncNative, EnableRequiredTransferMemos, @@ -2127,6 +2128,34 @@ pub fn app<'a>( .help("The address of the SPL Token mint, account, or multisig to query"), ), ) + .subcommand( + SubCommand::with_name(CommandName::RecoverNested.into()) + .about("Recover tokens from a nested associated token account") + .arg( + Arg::with_name("owner_token_mint") + .validator(|s| is_valid_pubkey(s)) + .value_name("OWNER_TOKEN_MINT_ADDRESS") + .takes_value(true) + .index(1) + .required(true) + .help("Mint of the associated token account that owns the nested account"), + ) + .arg( + Arg::with_name("nested_token_mint") + .validator(|s| is_valid_pubkey(s)) + .value_name("NESTED_TOKEN_MINT_ADDRESS") + .takes_value(true) + .index(2) + .required(true) + .help( + "Mint of the tokens held in the nested associated token account; may \ + be the same as OWNER_TOKEN_MINT_ADDRESS", + ), + ) + .arg(owner_keypair_arg()) + .nonce_args(true) + .offline_args(), + ) .subcommand( SubCommand::with_name(CommandName::Gc.into()) .about("Cleanup unnecessary token accounts") diff --git a/clients/cli/src/command.rs b/clients/cli/src/command.rs index 48eecbcbd..6c0aa3ce1 100644 --- a/clients/cli/src/command.rs +++ b/clients/cli/src/command.rs @@ -43,7 +43,9 @@ use { elgamal::{self, ElGamalKeypair}, }, solana_zk_sdk_pod::encryption::elgamal::PodElGamalPubkey, - spl_associated_token_account_interface::address::get_associated_token_address_with_program_id, + spl_associated_token_account_interface::{ + address::get_associated_token_address_with_program_id, instruction::recover_nested, + }, spl_token_2022_interface::{ extension::{ confidential_mint_burn::ConfidentialMintBurn, @@ -3387,6 +3389,57 @@ async fn command_gc( Ok(results.join("")) } +async fn command_recover_nested( + config: &Config<'_>, + owner: Pubkey, + owner_token_mint: Pubkey, + nested_token_mint: Pubkey, + bulk_signers: BulkSigners, +) -> CommandResult { + let owner_associated_account = + get_associated_token_address_with_program_id(&owner, &owner_token_mint, &config.program_id); + let nested_associated_account = get_associated_token_address_with_program_id( + &owner_associated_account, + &nested_token_mint, + &config.program_id, + ); + let destination_associated_account = get_associated_token_address_with_program_id( + &owner, + &nested_token_mint, + &config.program_id, + ); + + println_display( + config, + format!( + "Recovering nested associated token account\n Owner: {}\n Owner associated account: {}\n Nested associated account: {}\n Destination associated account: {}", + owner, + owner_associated_account, + nested_associated_account, + destination_associated_account, + ), + ); + + let instruction = recover_nested( + &owner, + &owner_token_mint, + &nested_token_mint, + &config.program_id, + ); + let token = token_client_from_config(config, &nested_token_mint, None)?; + let res = token.process_ixs(&[instruction], &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_sync_native(config: &Config<'_>, native_account_address: Pubkey) -> CommandResult { let token = native_token_client_from_config(config)?; @@ -5284,6 +5337,28 @@ pub async fn process_command( .unwrap(); command_display(config, address).await } + (CommandName::RecoverNested, arg_matches) => { + let owner_token_mint = + pubkey_of_signer(arg_matches, "owner_token_mint", &mut wallet_manager) + .unwrap() + .unwrap(); + let nested_token_mint = + pubkey_of_signer(arg_matches, "nested_token_mint", &mut wallet_manager) + .unwrap() + .unwrap(); + let (owner_signer, owner) = + config.signer_or_default(arg_matches, "owner", &mut wallet_manager); + push_signer_with_dedup(owner_signer, &mut bulk_signers); + + command_recover_nested( + config, + owner, + owner_token_mint, + nested_token_mint, + bulk_signers, + ) + .await + } (CommandName::Gc, arg_matches) => { match config.output_format { OutputFormat::Json | OutputFormat::JsonCompact => { diff --git a/clients/cli/tests/command.rs b/clients/cli/tests/command.rs index 1ea88198d..8d6a8a678 100644 --- a/clients/cli/tests/command.rs +++ b/clients/cli/tests/command.rs @@ -160,6 +160,7 @@ async fn main() { async_trial!(multisig_pause, test_validator, payer), async_trial!(permissioned_burn, test_validator, payer), async_trial!(confidential_mint_burn, test_validator, payer), + async_trial!(recover_nested, test_validator, payer), // GC messes with every other test, so have it on its own test validator async_trial!(gc, gc_test_validator, gc_payer), ]; @@ -1575,6 +1576,138 @@ async fn disable_mint_authority(test_validator: &TestValidator, payer: &Keypair) } } +async fn recover_nested(test_validator: &TestValidator, payer: &Keypair) { + for program_id in VALID_TOKEN_PROGRAM_IDS.iter() { + for same_mint in [false, true] { + let config = test_config_with_default_signer(test_validator, payer, program_id); + let owner = Keypair::new(); + let owner_keypair_file = NamedTempFile::new().unwrap(); + write_keypair_file(&owner, &owner_keypair_file).unwrap(); + let fee_payer_keypair_file = NamedTempFile::new().unwrap(); + write_keypair_file(payer, &fee_payer_keypair_file).unwrap(); + let blockhash = config.rpc_client.get_latest_blockhash().await.unwrap(); + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::transfer( + &payer.pubkey(), + &owner.pubkey(), + 1_000_000, + )], + Some(&payer.pubkey()), + &[payer], + blockhash, + ); + config + .rpc_client + .send_and_confirm_transaction(&transaction) + .await + .unwrap(); + + let owner_token_mint = create_token(&config, payer).await; + let nested_token_mint = if same_mint { + owner_token_mint + } else { + create_token(&config, payer).await + }; + let owner_associated_account = + create_associated_account(&config, payer, &owner_token_mint, &owner.pubkey()).await; + let destination_associated_account = if same_mint { + owner_associated_account + } else { + create_associated_account(&config, payer, &nested_token_mint, &owner.pubkey()).await + }; + let nested_associated_account = create_associated_account( + &config, + payer, + &nested_token_mint, + &owner_associated_account, + ) + .await; + + mint_tokens( + &config, + payer, + nested_token_mint, + 1.0, + nested_associated_account, + ) + .await + .unwrap(); + let nested_account_lamports = config + .rpc_client + .get_account(&nested_associated_account) + .await + .unwrap() + .lamports; + for token_account in [ + owner_associated_account, + destination_associated_account, + nested_associated_account, + ] { + assert_eq!( + config + .rpc_client + .get_account(&token_account) + .await + .unwrap() + .owner, + *program_id, + "token account {token_account} has the wrong program owner" + ); + } + let owner_lamports_before = config + .rpc_client + .get_balance(&owner.pubkey()) + .await + .unwrap(); + exec_test_cmd( + &config, + &[ + "spl-token", + CommandName::RecoverNested.into(), + &owner_token_mint.to_string(), + &nested_token_mint.to_string(), + "--owner", + owner_keypair_file.path().to_str().unwrap(), + "--fee-payer", + fee_payer_keypair_file.path().to_str().unwrap(), + "--program-id", + &program_id.to_string(), + ], + ) + .await + .unwrap_or_else(|error| { + panic!( + "recover-nested failed for program {program_id} with same_mint={same_mint}: \ + {error}" + ) + }); + + let destination = config + .rpc_client + .get_token_account(&destination_associated_account) + .await + .unwrap() + .unwrap(); + let expected_amount = spl_token_2022::ui_amount_to_amount(1.0, TEST_DECIMALS); + assert_eq!(destination.token_amount.amount, expected_amount.to_string()); + config + .rpc_client + .get_account(&nested_associated_account) + .await + .unwrap_err(); + let owner_lamports_after = config + .rpc_client + .get_balance(&owner.pubkey()) + .await + .unwrap(); + assert_eq!( + owner_lamports_after, + owner_lamports_before + nested_account_lamports + ); + } + } +} + async fn gc(test_validator: &TestValidator, payer: &Keypair) { for program_id in VALID_TOKEN_PROGRAM_IDS.iter() { let mut config = test_config_with_default_signer(test_validator, payer, program_id);