diff --git a/libraries/tlv-account-resolution/src/state.rs b/libraries/tlv-account-resolution/src/state.rs index 6aa84b8c903..7d8234e6338 100644 --- a/libraries/tlv-account-resolution/src/state.rs +++ b/libraries/tlv-account-resolution/src/state.rs @@ -182,6 +182,22 @@ impl ExtraAccountMetaList { Ok(()) } + /// Update pod slice data for the given instruction and its required + /// list of `ExtraAccountMeta`s + pub fn update( + data: &mut [u8], + extra_account_metas: &[ExtraAccountMeta], + ) -> Result<(), ProgramError> { + let mut state = TlvStateMut::unpack(data).unwrap(); + let tlv_size = PodSlice::::size_of(extra_account_metas.len())?; + let bytes = state.realloc_first::(tlv_size)?; + let mut validation_data = PodSliceMut::init(bytes)?; + for meta in extra_account_metas { + validation_data.push(*meta)?; + } + Ok(()) + } + /// Get the underlying `PodSlice` from an unpacked TLV /// /// Due to lifetime annoyances, this function can't just take in the bytes, @@ -1292,6 +1308,124 @@ mod tests { } } + async fn update_and_assert_metas( + program_id: Pubkey, + buffer: &mut Vec, + updated_metas: &[ExtraAccountMeta], + check_metas: &[AccountMeta], + ) { + // resize buffer if necessary + let account_size = ExtraAccountMetaList::size_of(updated_metas.len()).unwrap(); + if account_size > buffer.len() { + buffer.resize(account_size, 0); + } + + // update + ExtraAccountMetaList::update::(buffer, updated_metas).unwrap(); + + // retrieve metas and assert + let state = TlvStateBorrowed::unpack(buffer).unwrap(); + let unpacked_metas_pod = + ExtraAccountMetaList::unpack_with_tlv_state::(&state).unwrap(); + let unpacked_metas = unpacked_metas_pod.data(); + assert_eq!( + unpacked_metas, updated_metas, + "The ExtraAccountMetas in the buffer should match the expected ones." + ); + + let mock_rpc = MockRpc::setup(&[]); + + let mut instruction = Instruction::new_with_bytes(program_id, &[], vec![]); + ExtraAccountMetaList::add_to_instruction::( + &mut instruction, + |pubkey| mock_rpc.get_account_data(pubkey), + buffer, + ) + .await + .unwrap(); + + assert_eq!(instruction.accounts, check_metas,); + } + + #[tokio::test] + async fn update_extra_account_meta_list() { + let program_id = Pubkey::new_unique(); + + // Create list of initial metas + let initial_metas = [ + ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), false, true).unwrap(), + ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), true, false).unwrap(), + ]; + + // initialize + let initial_account_size = ExtraAccountMetaList::size_of(initial_metas.len()).unwrap(); + let mut buffer = vec![0; initial_account_size]; + ExtraAccountMetaList::init::(&mut buffer, &initial_metas).unwrap(); + + // Create updated metas list of the same size + let updated_metas_1 = [ + ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), true, true).unwrap(), + ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), false, false).unwrap(), + ]; + let check_metas_1 = updated_metas_1 + .iter() + .map(|e| AccountMeta::try_from(e).unwrap()) + .collect::>(); + update_and_assert_metas(program_id, &mut buffer, &updated_metas_1, &check_metas_1).await; + + // Create updated and larger list of metas + let updated_metas_2 = [ + ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), true, true).unwrap(), + ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), false, true).unwrap(), + ]; + let check_metas_2 = updated_metas_2 + .iter() + .map(|e| AccountMeta::try_from(e).unwrap()) + .collect::>(); + update_and_assert_metas(program_id, &mut buffer, &updated_metas_2, &check_metas_2).await; + + // Create updated and smaller list of metas + let updated_metas_3 = + [ExtraAccountMeta::new_with_pubkey(&Pubkey::new_unique(), true, true).unwrap()]; + let check_metas_3 = updated_metas_3 + .iter() + .map(|e| AccountMeta::try_from(e).unwrap()) + .collect::>(); + update_and_assert_metas(program_id, &mut buffer, &updated_metas_3, &check_metas_3).await; + + // Create updated list of metas with a simple PDA + let seed_pubkey = Pubkey::new_unique(); + let updated_metas_4 = [ + ExtraAccountMeta::new_with_pubkey(&seed_pubkey, true, true).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ]; + let simple_pda = Pubkey::find_program_address( + &[ + b"seed-prefix", // Literal prefix + seed_pubkey.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + let check_metas_4 = [ + AccountMeta::new(seed_pubkey, true), + AccountMeta::new(simple_pda, false), + ]; + + update_and_assert_metas(program_id, &mut buffer, &updated_metas_4, &check_metas_4).await; + } + #[test] fn check_account_infos_test() { let program_id = Pubkey::new_unique(); diff --git a/token/transfer-hook/cli/src/main.rs b/token/transfer-hook/cli/src/main.rs index 63d11f8903f..06b078887b2 100644 --- a/token/transfer-hook/cli/src/main.rs +++ b/token/transfer-hook/cli/src/main.rs @@ -9,15 +9,16 @@ use { solana_remote_wallet::remote_wallet::RemoteWalletManager, solana_sdk::{ commitment_config::CommitmentConfig, - instruction::AccountMeta, + instruction::{AccountMeta, Instruction}, pubkey::Pubkey, signature::{Signature, Signer}, system_instruction, system_program, transaction::Transaction, }, - spl_tlv_account_resolution::state::ExtraAccountMetaList, + spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList}, spl_transfer_hook_interface::{ - get_extra_account_metas_address, instruction::initialize_extra_account_meta_list, + get_extra_account_metas_address, + instruction::{initialize_extra_account_meta_list, update_extra_account_meta_list}, }, std::{fmt, process::exit, rc::Rc, str::FromStr}, strum_macros::{EnumString, IntoStaticStr}, @@ -56,6 +57,74 @@ fn clap_is_valid_pubkey(arg: &str) -> Result<(), String> { is_valid_pubkey(arg) } +// Helper function to calculate the required lamports for rent +async fn calculate_rent_lamports( + rpc_client: &RpcClient, + account_address: &Pubkey, + account_size: usize, +) -> Result> { + let required_lamports = rpc_client + .get_minimum_balance_for_rent_exemption(account_size) + .await + .map_err(|err| format!("error: unable to fetch rent-exemption: {err}"))?; + let account_info = rpc_client.get_account(account_address).await; + let current_lamports = account_info.map(|a| a.lamports).unwrap_or(0); + Ok(required_lamports.saturating_sub(current_lamports)) +} + +async fn build_transaction_with_rent_transfer( + rpc_client: &RpcClient, + payer: &dyn Signer, + extra_account_metas_address: &Pubkey, + extra_account_metas: &Vec, + instruction: Instruction, +) -> Result> { + let account_size = ExtraAccountMetaList::size_of(extra_account_metas.len())?; + let transfer_lamports = + calculate_rent_lamports(rpc_client, extra_account_metas_address, account_size).await?; + + let mut instructions = vec![]; + if transfer_lamports > 0 { + instructions.push(system_instruction::transfer( + &payer.pubkey(), + extra_account_metas_address, + transfer_lamports, + )); + } + + instructions.push(instruction); + + let transaction = Transaction::new_with_payer(&instructions, Some(&payer.pubkey())); + + Ok(transaction) +} + +async fn sign_and_send_transaction( + transaction: &mut Transaction, + rpc_client: &RpcClient, + payer: &dyn Signer, + mint_authority: &dyn Signer, +) -> Result> { + let mut signers = vec![payer]; + if payer.pubkey() != mint_authority.pubkey() { + signers.push(mint_authority); + } + + let blockhash = rpc_client + .get_latest_blockhash() + .await + .map_err(|err| format!("error: unable to get latest blockhash: {err}"))?; + + transaction + .try_sign(&signers, blockhash) + .map_err(|err| format!("error: failed to sign transaction: {err}"))?; + + rpc_client + .send_and_confirm_transaction_with_spinner(transaction) + .await + .map_err(|err| format!("error: send transaction: {err}").into()) +} + struct Config { commitment_config: CommitmentConfig, default_signer: Box, @@ -72,59 +141,82 @@ async fn process_create_extra_account_metas( payer: &dyn Signer, ) -> Result> { let extra_account_metas_address = get_extra_account_metas_address(token, program_id); - let extra_account_metas = transfer_hook_accounts - .into_iter() - .map(|v| v.into()) - .collect::>(); - let length = extra_account_metas.len(); - let account_size = ExtraAccountMetaList::size_of(length)?; - let required_lamports = rpc_client - .get_minimum_balance_for_rent_exemption(account_size) - .await - .map_err(|err| format!("error: unable to fetch rent-exemption: {err}"))?; + // Check if the extra meta account has already been initialized let extra_account_metas_account = rpc_client.get_account(&extra_account_metas_address).await; if let Ok(account) = &extra_account_metas_account { if account.owner != system_program::id() { return Err(format!("error: extra account metas for mint {token} and program {program_id} already exists").into()); } } - let current_lamports = extra_account_metas_account.map(|a| a.lamports).unwrap_or(0); - let transfer_lamports = required_lamports.saturating_sub(current_lamports); - let mut ixs = vec![]; - if transfer_lamports > 0 { - ixs.push(system_instruction::transfer( - &payer.pubkey(), - &extra_account_metas_address, - transfer_lamports, - )); - } - ixs.push(initialize_extra_account_meta_list( + let extra_account_metas = transfer_hook_accounts + .into_iter() + .map(|v| v.into()) + .collect::>(); + + let instruction = initialize_extra_account_meta_list( program_id, &extra_account_metas_address, token, &mint_authority.pubkey(), &extra_account_metas, - )); + ); - let mut transaction = Transaction::new_with_payer(&ixs, Some(&payer.pubkey())); - let blockhash = rpc_client - .get_latest_blockhash() - .await - .map_err(|err| format!("error: unable to get latest blockhash: {err}"))?; - let mut signers = vec![payer]; - if payer.pubkey() != mint_authority.pubkey() { - signers.push(mint_authority); + let mut transaction = build_transaction_with_rent_transfer( + rpc_client, + payer, + &extra_account_metas_address, + &extra_account_metas, + instruction, + ) + .await?; + + sign_and_send_transaction(&mut transaction, rpc_client, payer, mint_authority).await +} + +async fn process_update_extra_account_metas( + rpc_client: &RpcClient, + program_id: &Pubkey, + token: &Pubkey, + transfer_hook_accounts: Vec, + mint_authority: &dyn Signer, + payer: &dyn Signer, +) -> Result> { + let extra_account_metas_address = get_extra_account_metas_address(token, program_id); + + // Check if the extra meta account has been initialized first + let extra_account_metas_account = rpc_client.get_account(&extra_account_metas_address).await; + if extra_account_metas_account.is_err() { + return Err(format!( + "error: extra account metas for mint {token} and program {program_id} does not exist" + ) + .into()); } - transaction - .try_sign(&signers, blockhash) - .map_err(|err| format!("error: failed to sign transaction: {err}"))?; - rpc_client - .send_and_confirm_transaction_with_spinner(&transaction) - .await - .map_err(|err| format!("error: send transaction: {err}").into()) + let extra_account_metas = transfer_hook_accounts + .into_iter() + .map(|v| v.into()) + .collect::>(); + + let instruction = update_extra_account_meta_list( + program_id, + &extra_account_metas_address, + token, + &mint_authority.pubkey(), + &extra_account_metas, + ); + + let mut transaction = build_transaction_with_rent_transfer( + rpc_client, + payer, + &extra_account_metas_address, + &extra_account_metas, + instruction, + ) + .await?; + + sign_and_send_transaction(&mut transaction, rpc_client, payer, mint_authority).await } #[tokio::main] @@ -217,6 +309,49 @@ async fn main() -> Result<(), Box> { .global(true) .help("Filepath or URL to mint-authority keypair [default: client keypair]"), ) + ) + .subcommand( + Command::new("update-extra-metas") + .about("Update the extra account metas account for a transfer hook program") + .arg( + Arg::with_name("program_id") + .validator(clap_is_valid_pubkey) + .value_name("TRANSFER_HOOK_PROGRAM") + .takes_value(true) + .index(1) + .required(true) + .help("The transfer hook program id"), + ) + .arg( + Arg::with_name("token") + .validator(clap_is_valid_pubkey) + .value_name("TOKEN_MINT_ADDRESS") + .takes_value(true) + .index(2) + .required(true) + .help("The token mint address for the transfer hook"), + ) + .arg( + Arg::with_name("transfer_hook_account") + .value_parser(parse_transfer_hook_account) + .value_name("PUBKEY:ROLE") + .takes_value(true) + .multiple(true) + .min_values(0) + .index(3) + .help("Additional pubkey(s) required for a transfer hook and their \ + role, in the format \":\". The role must be \ + \"readonly\", \"writable\". \"readonly-signer\", or \"writable-signer\".") + ) + .arg( + Arg::new("mint_authority") + .long("mint-authority") + .value_name("KEYPAIR") + .validator(|s| is_valid_signer(s)) + .takes_value(true) + .global(true) + .help("Filepath or URL to mint-authority keypair [default: client keypair]"), + ) ).get_matches(); let (command, matches) = app_matches.subcommand().unwrap(); @@ -303,6 +438,45 @@ async fn main() -> Result<(), Box> { }); println!("Signature: {signature}"); } + ("update-extra-metas", arg_matches) => { + let program_id = pubkey_of_signer(arg_matches, "program_id", &mut wallet_manager) + .unwrap() + .unwrap(); + let token = pubkey_of_signer(arg_matches, "token", &mut wallet_manager) + .unwrap() + .unwrap(); + let transfer_hook_accounts = arg_matches + .get_many::("transfer_hook_account") + .unwrap_or_default() + .cloned() + .collect(); + let mint_authority = DefaultSigner::new( + "mint_authority", + matches + .value_of("mint_authority") + .map(|s| s.to_string()) + .unwrap_or_else(|| cli_config.keypair_path.clone()), + ) + .signer_from_path(matches, &mut wallet_manager) + .unwrap_or_else(|err| { + eprintln!("error: {err}"); + exit(1); + }); + let signature = process_update_extra_account_metas( + &rpc_client, + &program_id, + &token, + transfer_hook_accounts, + mint_authority.as_ref(), + config.default_signer.as_ref(), + ) + .await + .unwrap_or_else(|err| { + eprintln!("error: send transaction: {err}"); + exit(1); + }); + println!("Signature: {signature}"); + } _ => unreachable!(), }; diff --git a/token/transfer-hook/example/src/processor.rs b/token/transfer-hook/example/src/processor.rs index 16b2d1f11a7..e398b207845 100644 --- a/token/transfer-hook/example/src/processor.rs +++ b/token/transfer-hook/example/src/processor.rs @@ -134,6 +134,68 @@ pub fn process_initialize_extra_account_meta_list( Ok(()) } +/// Processes a +/// [UpdateExtraAccountMetaList](enum.TransferHookInstruction.html) +/// instruction. +pub fn process_update_extra_account_meta_list( + program_id: &Pubkey, + accounts: &[AccountInfo], + extra_account_metas: &[ExtraAccountMeta], +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let extra_account_metas_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let authority_info = next_account_info(account_info_iter)?; + + // check that the mint authority is valid without fully deserializing + let mint_data = mint_info.try_borrow_data()?; + let mint = StateWithExtensions::::unpack(&mint_data)?; + let mint_authority = mint + .base + .mint_authority + .ok_or(TransferHookError::MintHasNoMintAuthority)?; + + // Check signers + if !authority_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + if *authority_info.key != mint_authority { + return Err(TransferHookError::IncorrectMintAuthority.into()); + } + + // Check validation account + let expected_validation_address = get_extra_account_metas_address(mint_info.key, program_id); + if expected_validation_address != *extra_account_metas_info.key { + return Err(ProgramError::InvalidSeeds); + } + + // Check if the extra metas have been initialized + let min_account_size = ExtraAccountMetaList::size_of(0)?; + let original_account_size = extra_account_metas_info.data_len(); + if program_id != extra_account_metas_info.owner || original_account_size < min_account_size { + return Err(ProgramError::UninitializedAccount); + } + + // If the new extra_account_metas length is different, resize the account and + // update + let length = extra_account_metas.len(); + let account_size = ExtraAccountMetaList::size_of(length)?; + if account_size >= original_account_size { + extra_account_metas_info.realloc(account_size, false)?; + let mut data = extra_account_metas_info.try_borrow_mut_data()?; + ExtraAccountMetaList::update::(&mut data, extra_account_metas)?; + } else { + { + let mut data = extra_account_metas_info.try_borrow_mut_data()?; + ExtraAccountMetaList::update::(&mut data, extra_account_metas)?; + } + extra_account_metas_info.realloc(account_size, false)?; + } + + Ok(()) +} + /// Processes an [Instruction](enum.Instruction.html). pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { let instruction = TransferHookInstruction::unpack(input)?; @@ -149,5 +211,11 @@ pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> P msg!("Instruction: InitializeExtraAccountMetaList"); process_initialize_extra_account_meta_list(program_id, accounts, &extra_account_metas) } + TransferHookInstruction::UpdateExtraAccountMetaList { + extra_account_metas, + } => { + msg!("Instruction: UpdateExtraAccountMetaList"); + process_update_extra_account_meta_list(program_id, accounts, &extra_account_metas) + } } } diff --git a/token/transfer-hook/example/tests/functional.rs b/token/transfer-hook/example/tests/functional.rs index 989b6f901ce..4e078c1fde0 100644 --- a/token/transfer-hook/example/tests/functional.rs +++ b/token/transfer-hook/example/tests/functional.rs @@ -28,7 +28,10 @@ use { spl_transfer_hook_interface::{ error::TransferHookError, get_extra_account_metas_address, - instruction::{execute_with_extra_account_metas, initialize_extra_account_meta_list}, + instruction::{ + execute_with_extra_account_metas, initialize_extra_account_meta_list, + update_extra_account_meta_list, + }, onchain, }, }; @@ -747,3 +750,649 @@ async fn fail_without_transferring_flag() { ) ); } + +#[tokio::test] +async fn success_on_chain_invoke_with_updated_extra_account_metas() { + let hook_program_id = Pubkey::new_unique(); + let mut program_test = setup(&hook_program_id); + let program_id = Pubkey::new_unique(); + program_test.add_program( + "test_cpi_program", + program_id, + processor!(process_instruction), + ); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = Pubkey::new_unique(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + let amount = 0u64; + + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + let extra_account_metas_address = + get_extra_account_metas_address(&mint_address, &hook_program_id); + let writable_pubkey = Pubkey::new_unique(); + + // Create an initial account metas list + let init_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"init-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let mut context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap()); + let init_transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + initialize_extra_account_meta_list( + &hook_program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &init_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(init_transaction) + .await + .unwrap(); + + // Create an updated account metas list + let updated_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"updated-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(updated_extra_account_metas.len()).unwrap()); + let update_transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + update_extra_account_meta_list( + &hook_program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &updated_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(update_transaction) + .await + .unwrap(); + + let updated_extra_pda_1 = Pubkey::find_program_address( + &[ + b"updated-seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &hook_program_id, + ) + .0; + let extra_pda_2 = Pubkey::find_program_address( + &[ + &amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &hook_program_id, + ) + .0; + + let test_updated_extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + + // Use updated account metas list + let mut test_instruction = execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &test_updated_extra_account_metas, + amount, + ); + test_instruction + .accounts + .insert(0, AccountMeta::new_readonly(hook_program_id, false)); + let transaction = Transaction::new_signed_with_payer( + &[test_instruction], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +#[tokio::test] +async fn success_execute_with_updated_extra_account_metas() { + let program_id = Pubkey::new_unique(); + let mut program_test = setup(&program_id); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = Pubkey::new_unique(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + let amount = 0u64; + + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + let extra_account_metas_address = get_extra_account_metas_address(&mint_address, &program_id); + + let writable_pubkey = Pubkey::new_unique(); + + let init_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let extra_pda_1 = Pubkey::find_program_address( + &[ + b"seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + + let extra_pda_2 = Pubkey::find_program_address( + &[ + &amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &program_id, + ) + .0; + + let init_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + + let mut context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap()); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + initialize_extra_account_meta_list( + &program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &init_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let updated_amount = 1u64; + let updated_writable_pubkey = Pubkey::new_unique(); + + // Create updated extra account metas + let updated_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"updated-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&updated_writable_pubkey, false, true).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"new-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ]; + + let updated_extra_pda_1 = Pubkey::find_program_address( + &[ + b"updated-seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + + let updated_extra_pda_2 = Pubkey::find_program_address( + &[ + &updated_amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &program_id, + ) + .0; + + // add another PDA + let new_extra_pda = Pubkey::find_program_address( + &[ + b"new-seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + + let updated_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(updated_extra_pda_2, false), + AccountMeta::new(updated_writable_pubkey, false), + AccountMeta::new(new_extra_pda, false), + ]; + + let update_transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + update_extra_account_meta_list( + &program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &updated_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(update_transaction) + .await + .unwrap(); + + // fail with initial account metas list + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &init_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with missing account + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &updated_account_metas[..2], + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with wrong account + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(updated_extra_pda_2, false), + AccountMeta::new(Pubkey::new_unique(), false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with wrong PDA + let wrong_pda_2 = Pubkey::find_program_address( + &[ + &99u64.to_le_bytes(), // Wrong data + destination.as_ref(), + ], + &program_id, + ) + .0; + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(wrong_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with not signer + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, false), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(updated_extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // success with correct params + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &updated_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + } +} diff --git a/token/transfer-hook/interface/src/instruction.rs b/token/transfer-hook/interface/src/instruction.rs index 5784f1b0986..39639d5d646 100644 --- a/token/transfer-hook/interface/src/instruction.rs +++ b/token/transfer-hook/interface/src/instruction.rs @@ -32,8 +32,9 @@ pub enum TransferHookInstruction { /// Amount of tokens to transfer amount: u64, }, - /// Initializes the extra account metas on an account, writing into - /// the first open TLV space. + + /// Initializes the extra account metas on an account, writing into the + /// first open TLV space. /// /// Accounts expected by this instruction: /// @@ -45,6 +46,19 @@ pub enum TransferHookInstruction { /// List of `ExtraAccountMeta`s to write into the account extra_account_metas: Vec, }, + /// Updates the extra account metas on an account by overwriting the + /// existing list. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Account with extra account metas + /// 1. `[]` Mint + /// 2. `[s]` Mint authority + UpdateExtraAccountMetaList { + /// The new list of `ExtraAccountMetas` to overwrite the existing entry + /// in the account. + extra_account_metas: Vec, + }, } /// TLV instruction type only used to define the discriminator. The actual data /// is entirely managed by `ExtraAccountMetaList`, and it is the only data @@ -59,6 +73,12 @@ pub struct ExecuteInstruction; #[discriminator_hash_input("spl-transfer-hook-interface:initialize-extra-account-metas")] pub struct InitializeExtraAccountMetaListInstruction; +/// TLV instruction type used to update extra account metas +/// for the transfer hook +#[derive(SplDiscriminate)] +#[discriminator_hash_input("spl-transfer-hook-interface:update-extra-account-metas")] +pub struct UpdateExtraAccountMetaListInstruction; + impl TransferHookInstruction { /// Unpacks a byte buffer into a /// [TransferHookInstruction](enum.TransferHookInstruction.html). @@ -83,6 +103,13 @@ impl TransferHookInstruction { extra_account_metas, } } + UpdateExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE => { + let pod_slice = PodSlice::::unpack(rest)?; + let extra_account_metas = pod_slice.data().to_vec(); + Self::UpdateExtraAccountMetaList { + extra_account_metas, + } + } _ => return Err(ProgramError::InvalidInstructionData), }) } @@ -105,6 +132,15 @@ impl TransferHookInstruction { buf.extend_from_slice(&(extra_account_metas.len() as u32).to_le_bytes()); buf.extend_from_slice(pod_slice_to_bytes(extra_account_metas)); } + Self::UpdateExtraAccountMetaList { + extra_account_metas, + } => { + buf.extend_from_slice( + UpdateExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE, + ); + buf.extend_from_slice(&(extra_account_metas.len() as u32).to_le_bytes()); + buf.extend_from_slice(pod_slice_to_bytes(extra_account_metas)); + } }; buf } @@ -189,6 +225,32 @@ pub fn initialize_extra_account_meta_list( } } +/// Creates a `UpdateExtraAccountMetaList` instruction. +pub fn update_extra_account_meta_list( + program_id: &Pubkey, + extra_account_metas_pubkey: &Pubkey, + mint_pubkey: &Pubkey, + authority_pubkey: &Pubkey, + extra_account_metas: &[ExtraAccountMeta], +) -> Instruction { + let data = TransferHookInstruction::UpdateExtraAccountMetaList { + extra_account_metas: extra_account_metas.to_vec(), + } + .pack(); + + let accounts = vec![ + AccountMeta::new(*extra_account_metas_pubkey, false), + AccountMeta::new_readonly(*mint_pubkey, false), + AccountMeta::new_readonly(*authority_pubkey, true), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + #[cfg(test)] mod test { use {super::*, crate::NAMESPACE, solana_program::hash, spl_pod::bytemuck::pod_from_bytes};