diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml index dfb7f8a7d..abbe2f0bb 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml @@ -17,4 +17,8 @@ cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "../node_modules/.bin/mocha --import=tsx -t 1000000 tests/**/*.ts" +# cargo test runs both the tx_hook.rs unit tests and the litesvm +# integration test - CI's Anchor workflow only runs this [scripts] test +# command (never a separate `cargo test`), so without this the Rust-side +# tests were never actually executed. +test = "cargo test -p abl-token && ../node_modules/.bin/mocha --import=tsx -t 1000000 tests/**/*.ts" diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Cargo.toml b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Cargo.toml index 9099c0718..402694810 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Cargo.toml +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Cargo.toml @@ -34,6 +34,7 @@ spl-discriminator = "0.5.1" [dev-dependencies] litesvm = "0.11.0" +solana-account = "3.2.0" solana-instruction = "3.0.0" solana-keypair = "3.0.1" solana-message = "3.1.0" diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/errors.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/errors.rs index baabd5524..409e85d1f 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/errors.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/errors.rs @@ -13,4 +13,7 @@ pub enum ABListError { #[msg("Wallet blocked")] WalletBlocked, + + #[msg("Mint is not configured to use this transfer hook program")] + MintNotUsingThisHook, } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs index f0071a45c..3dfee297e 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs @@ -8,8 +8,8 @@ use anchor_spl::{ Token2022, }, token_interface::{ - spl_token_metadata_interface::state::Field, token_metadata_update_field, - Mint as MintAccount, TokenMetadataUpdateField, + spl_token_metadata_interface::state::Field, token_metadata_update_field, Mint as MintAccount, + TokenMetadataUpdateField, }, }; @@ -50,11 +50,7 @@ impl ChangeMode<'_> { token_metadata_update_field(cpi_ctx, Field::Key("AB".to_string()), args.mode.to_string())?; if args.mode == Mode::Mixed || self.has_threshold()? { - let threshold = if args.mode == Mode::Mixed { - args.threshold - } else { - 0 - }; + let threshold = if args.mode == Mode::Mixed { args.threshold } else { 0 }; let cpi_accounts = TokenMetadataUpdateField { metadata: self.mint.to_account_info(), @@ -64,11 +60,7 @@ impl ChangeMode<'_> { let cpi_program = self.token_program.key(); let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); - token_metadata_update_field( - cpi_ctx, - Field::Key("threshold".to_string()), - threshold.to_string(), - )?; + token_metadata_update_field(cpi_ctx, Field::Key("threshold".to_string()), threshold.to_string())?; } let data = self.mint.to_account_info().data_len(); @@ -80,11 +72,7 @@ impl ChangeMode<'_> { &self.mint.to_account_info().key(), min_balance - self.mint.to_account_info().get_lamports(), ), - &[ - self.authority.to_account_info(), - self.mint.to_account_info(), - self.system_program.to_account_info(), - ], + &[self.authority.to_account_info(), self.mint.to_account_info(), self.system_program.to_account_info()], )?; } @@ -96,11 +84,6 @@ impl ChangeMode<'_> { let mint_data = mint_info.data.borrow(); let mint = StateWithExtensions::::unpack(&mint_data)?; let metadata = mint.get_variable_len_extension::(); - Ok(metadata.is_ok() - && metadata - .unwrap() - .additional_metadata - .iter() - .any(|(key, _)| key == "threshold")) + Ok(metadata.is_ok() && metadata.unwrap().additional_metadata.iter().any(|(key, _)| key == "threshold")) } } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs index cebbdeeeb..a3c0ac6ed 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs @@ -20,10 +20,7 @@ pub struct InitConfig<'info> { impl InitConfig<'_> { pub fn init_config(&mut self, config_bump: u8) -> Result<()> { - self.config.set_inner(Config { - authority: self.payer.key(), - bump: config_bump, - }); + self.config.set_inner(Config { authority: self.payer.key(), bump: config_bump }); Ok(()) } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs index 7c0e0c812..676a12aba 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs @@ -1,11 +1,9 @@ -use anchor_lang::{ - prelude::*, solana_program::program::invoke, solana_program::system_instruction::transfer, -}; +use anchor_lang::{prelude::*, solana_program::program::invoke, solana_program::system_instruction::transfer}; use anchor_spl::{ token_2022::Token2022, token_interface::{ - spl_token_metadata_interface::state::Field, token_metadata_initialize, - token_metadata_update_field, Mint, TokenMetadataInitialize, TokenMetadataUpdateField, + spl_token_metadata_interface::state::Field, token_metadata_initialize, token_metadata_update_field, Mint, + TokenMetadataInitialize, TokenMetadataUpdateField, }, }; @@ -80,11 +78,7 @@ impl InitMint<'_> { }; let cpi_ctx = CpiContext::new(self.token_program.key(), cpi_accounts); - token_metadata_update_field( - cpi_ctx, - Field::Key("threshold".to_string()), - args.threshold.to_string(), - )?; + token_metadata_update_field(cpi_ctx, Field::Key("threshold".to_string()), args.threshold.to_string())?; } let data = self.mint.to_account_info().data_len(); @@ -96,11 +90,7 @@ impl InitMint<'_> { &self.mint.to_account_info().key(), min_balance - self.mint.to_account_info().get_lamports(), ), - &[ - self.payer.to_account_info(), - self.mint.to_account_info(), - self.system_program.to_account_info(), - ], + &[self.payer.to_account_info(), self.mint.to_account_info(), self.system_program.to_account_info()], )?; } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/mod.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/mod.rs index dd7b6053c..b7fea8785 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/mod.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/mod.rs @@ -4,6 +4,7 @@ pub mod init_config; pub mod init_mint; pub mod init_wallet; pub mod remove_wallet; +pub mod resize_meta_list; pub mod tx_hook; pub use attach_to_mint::*; @@ -12,4 +13,5 @@ pub use init_config::*; pub use init_mint::*; pub use init_wallet::*; pub use remove_wallet::*; +pub use resize_meta_list::*; pub use tx_hook::*; diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/resize_meta_list.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/resize_meta_list.rs new file mode 100644 index 000000000..13b615384 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/resize_meta_list.rs @@ -0,0 +1,76 @@ +use anchor_lang::{prelude::*, solana_program::program::invoke, solana_program::system_instruction::transfer}; +use anchor_spl::{ + token_2022::{ + spl_token_2022::{ + extension::{transfer_hook, StateWithExtensions}, + state::Mint as MintState, + }, + Token2022, + }, + token_interface::Mint, +}; + +use spl_tlv_account_resolution::state::ExtraAccountMetaList; +use spl_transfer_hook_interface::instruction::ExecuteInstruction; + +use crate::{get_extra_account_metas, get_meta_list_size, ABListError, META_LIST_ACCOUNT_SEED}; + +/// Rewrites an existing mint's extra-metas account to the current +/// `get_extra_account_metas()` layout, reallocating it if the size changed. +/// Permissionless: the content is fully determined by `mint` and this +/// program's own fixed extra-account list, so nothing needs a signature - +/// gating on the transfer-hook authority would strand mints whose authority +/// was revoked. +#[derive(Accounts)] +pub struct ResizeMetaList<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + #[account(mint::token_program = token_program)] + pub mint: Box>, + + #[account( + mut, + seeds = [META_LIST_ACCOUNT_SEED, mint.key().as_ref()], + bump, + )] + /// CHECK: extra metas account + pub extra_metas_account: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, + + pub token_program: Program<'info, Token2022>, +} + +impl ResizeMetaList<'_> { + pub fn resize_meta_list(&mut self) -> Result<()> { + // Confirm the mint is actually configured to use this hook program - + // read directly from its TransferHook extension, no CPI needed. + let configured_program_id = { + let mint_info = self.mint.to_account_info(); + let mint_data = mint_info.data.borrow(); + let mint_state = StateWithExtensions::::unpack(&mint_data)?; + transfer_hook::get_program_id(&mint_state) + }; + require!(configured_program_id == Some(crate::ID_CONST), ABListError::MintNotUsingThisHook); + + let account_info = self.extra_metas_account.to_account_info(); + let new_size = get_meta_list_size()?; + + let min_balance = Rent::get()?.minimum_balance(new_size); + if min_balance > account_info.lamports() { + invoke( + &transfer(&self.payer.key(), account_info.key, min_balance - account_info.lamports()), + &[self.payer.to_account_info(), account_info.clone(), self.system_program.to_account_info()], + )?; + } + account_info.resize(new_size)?; + + let metas = get_extra_account_metas()?; + let mut data = account_info.try_borrow_mut_data()?; + ExtraAccountMetaList::update::(&mut data, &metas) + .map_err(|_| ProgramError::InvalidAccountData)?; + + Ok(()) + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs index 01e807c4e..19df07c61 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs @@ -24,7 +24,9 @@ pub struct TxHook<'info> { /// CHECK: pub meta_list: UncheckedAccount<'info>, /// CHECK: - pub ab_wallet: UncheckedAccount<'info>, + pub source_ab_wallet: UncheckedAccount<'info>, + /// CHECK: + pub destination_ab_wallet: UncheckedAccount<'info>, } impl TxHook<'_> { @@ -35,31 +37,18 @@ impl TxHook<'_> { let metadata = mint.get_variable_len_extension::()?; let decoded_mode = Self::decode_metadata(&metadata)?; - let decoded_wallet_mode = self.decode_wallet_mode()?; - - match (decoded_mode, decoded_wallet_mode) { - // first check the force allow modes - (DecodedMintMode::Allow, DecodedWalletMode::Allow) => Ok(()), - (DecodedMintMode::Allow, _) => Err(ABListError::WalletNotAllowed.into()), - // then check if the wallet is blocked - (_, DecodedWalletMode::Block) => Err(ABListError::WalletBlocked.into()), - (DecodedMintMode::Block, _) => Ok(()), - // lastly check the threshold mode - (DecodedMintMode::Threshold(threshold), DecodedWalletMode::None) - if amount >= threshold => - { - Err(ABListError::AmountNotAllowed.into()) - } - (DecodedMintMode::Threshold(_), _) => Ok(()), - } + let source_wallet_mode = Self::decode_wallet_mode(&self.source_ab_wallet)?; + let destination_wallet_mode = Self::decode_wallet_mode(&self.destination_ab_wallet)?; + + decide(decoded_mode, source_wallet_mode, destination_wallet_mode, amount) } - fn decode_wallet_mode(&self) -> Result { - if self.ab_wallet.data_is_empty() { + fn decode_wallet_mode(account: &UncheckedAccount) -> Result { + if account.data_is_empty() { return Ok(DecodedWalletMode::None); } - let wallet_data = &mut self.ab_wallet.data.borrow(); + let wallet_data = &mut account.data.borrow(); let wallet = ABWallet::try_deserialize(&mut &wallet_data[..])?; if wallet.allowed { @@ -106,14 +95,132 @@ impl TxHook<'_> { } } +/// The transfer decision, kept as a pure function of the decoded mint/wallet +/// state so it's directly unit-testable without needing real accounts. +/// +/// A wallet with an explicit `allowed: false` ABWallet record is blocked from +/// transacting entirely - neither sending nor receiving - regardless of the +/// mint's overall mode. This is checked first and applies to both sides. +/// +/// Beyond that, Allow/Threshold mode gate who may *receive* only, matching +/// this program's documented semantics (see README): Force Allow requires +/// the receiver to be explicitly allowed in; Threshold requires the receiver +/// to be explicitly allowed in for transfers at or above the threshold. +fn decide( + mint_mode: DecodedMintMode, + source_wallet_mode: DecodedWalletMode, + destination_wallet_mode: DecodedWalletMode, + amount: u64, +) -> Result<()> { + if source_wallet_mode == DecodedWalletMode::Block || destination_wallet_mode == DecodedWalletMode::Block { + return Err(ABListError::WalletBlocked.into()); + } + + match (mint_mode, destination_wallet_mode) { + // first check the force allow modes + (DecodedMintMode::Allow, DecodedWalletMode::Allow) => Ok(()), + (DecodedMintMode::Allow, _) => Err(ABListError::WalletNotAllowed.into()), + // block mode: neither wallet was explicitly blocked (checked above), so allow + (DecodedMintMode::Block, _) => Ok(()), + // lastly check the threshold mode + (DecodedMintMode::Threshold(threshold), DecodedWalletMode::None) if amount >= threshold => { + Err(ABListError::AmountNotAllowed.into()) + } + (DecodedMintMode::Threshold(_), _) => Ok(()), + } +} + +#[derive(Debug, PartialEq)] enum DecodedMintMode { Allow, Block, Threshold(u64), } +#[derive(Debug, PartialEq)] enum DecodedWalletMode { Allow, Block, None, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_blocked_is_always_rejected() { + // This is the exact case that was broken: a blocked SENDER used to + // be allowed through, since only the destination was ever checked. + for mint_mode in [DecodedMintMode::Allow, DecodedMintMode::Block, DecodedMintMode::Threshold(100)] { + for destination_mode in [DecodedWalletMode::Allow, DecodedWalletMode::Block, DecodedWalletMode::None] { + let result = decide(mint_mode_clone(&mint_mode), DecodedWalletMode::Block, destination_mode, 0); + assert!( + result.is_err(), + "expected a blocked source to be rejected regardless of mint mode / destination status" + ); + } + } + } + + #[test] + fn destination_blocked_is_always_rejected() { + // Regression guard: this already worked before the fix, must keep working. + for mint_mode in [DecodedMintMode::Allow, DecodedMintMode::Block, DecodedMintMode::Threshold(100)] { + for source_mode in [DecodedWalletMode::Allow, DecodedWalletMode::Block, DecodedWalletMode::None] { + let result = decide(mint_mode_clone(&mint_mode), source_mode, DecodedWalletMode::Block, 0); + assert!( + result.is_err(), + "expected a blocked destination to be rejected regardless of mint mode / source status" + ); + } + } + } + + #[test] + fn allow_mode_does_not_gate_the_source() { + // The source is intentionally NOT gated in Allow mode - only "who may + // receive" is documented/intended to be restricted. This is the + // control case proving the fix doesn't over-correct. + let result = decide(DecodedMintMode::Allow, DecodedWalletMode::None, DecodedWalletMode::Allow, 0); + assert!(result.is_ok()); + } + + #[test] + fn allow_mode_rejects_an_unlisted_destination() { + let result = decide(DecodedMintMode::Allow, DecodedWalletMode::None, DecodedWalletMode::None, 0); + assert!(result.is_err()); + } + + #[test] + fn block_mode_allows_unlisted_wallets() { + let result = decide(DecodedMintMode::Block, DecodedWalletMode::None, DecodedWalletMode::None, 0); + assert!(result.is_ok()); + } + + #[test] + fn threshold_mode_allows_small_transfers_to_unlisted_destinations() { + let result = decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::None, 50); + assert!(result.is_ok()); + } + + #[test] + fn threshold_mode_rejects_large_transfers_to_unlisted_destinations() { + let result = decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::None, 100); + assert!(result.is_err()); + } + + #[test] + fn threshold_mode_allows_large_transfers_to_an_allowed_destination() { + let result = decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::Allow, 100); + assert!(result.is_ok()); + } + + fn mint_mode_clone(mode: &DecodedMintMode) -> DecodedMintMode { + match mode { + DecodedMintMode::Allow => DecodedMintMode::Allow, + DecodedMintMode::Block => DecodedMintMode::Block, + DecodedMintMode::Threshold(t) => DecodedMintMode::Threshold(*t), + } + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs index b3c370483..f8eda4b6a 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs @@ -48,4 +48,8 @@ pub mod abl_token { pub fn change_mode(ctx: Context, args: ChangeModeArgs) -> Result<()> { ctx.accounts.change_mode(args) } + + pub fn resize_meta_list(ctx: Context) -> Result<()> { + ctx.accounts.resize_meta_list() + } } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs index 7ffc952ad..958911993 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs @@ -1,31 +1,38 @@ use anchor_lang::prelude::*; -use spl_tlv_account_resolution::{ - account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList, -}; +use spl_tlv_account_resolution::{account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList}; use crate::AB_WALLET_SEED; +/// The transfer hook resolves one extra account per side of a transfer: the +/// source wallet's ab_wallet PDA and the destination wallet's ab_wallet PDA. +const NUM_EXTRA_ACCOUNTS: usize = 2; + pub fn get_meta_list_size() -> Result { - Ok(ExtraAccountMetaList::size_of(1).map_err(|_| ProgramError::InvalidArgument)?) + Ok(ExtraAccountMetaList::size_of(NUM_EXTRA_ACCOUNTS).map_err(|_| ProgramError::InvalidArgument)?) } pub fn get_extra_account_metas() -> Result> { Ok(vec![ - // [5] ab_wallet for destination token account wallet + // [5] ab_wallet for source token account wallet + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { bytes: AB_WALLET_SEED.to_vec() }, + Seed::AccountData { account_index: 0, data_index: 32, length: 32 }, + ], + false, + false, + ) + .map_err(|_| ProgramError::InvalidArgument)?, // [0] source token account + // [6] ab_wallet for destination token account wallet ExtraAccountMeta::new_with_seeds( &[ - Seed::Literal { - bytes: AB_WALLET_SEED.to_vec(), - }, - Seed::AccountData { - account_index: 2, - data_index: 32, - length: 32, - }, + Seed::Literal { bytes: AB_WALLET_SEED.to_vec() }, + Seed::AccountData { account_index: 2, data_index: 32, length: 32 }, ], false, false, - ).map_err(|_| ProgramError::InvalidArgument)?, // [2] destination token account + ) + .map_err(|_| ProgramError::InvalidArgument)?, // [2] destination token account ]) } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs index 67d68661e..e2e0171a3 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs @@ -1,9 +1,18 @@ use { - abl_token::{accounts::InitConfig, accounts::InitMint, instructions::InitMintArgs, Mode}, + abl_token::{accounts::InitConfig, accounts::InitMint, accounts::ResizeMetaList, instructions::InitMintArgs, Mode}, + anchor_lang::solana_program::system_instruction::create_account, anchor_lang::InstructionData, anchor_lang::ToAccountMetas, - anchor_spl::token_2022::ID as TOKEN_22_PROGRAM_ID, + anchor_spl::token_2022::{ + spl_token_2022::{ + self, + extension::{transfer_hook, ExtensionType}, + instruction::initialize_mint2, + }, + ID as TOKEN_22_PROGRAM_ID, + }, litesvm::LiteSVM, + solana_account::Account, solana_instruction::Instruction, solana_keypair::Keypair, solana_message::Message, @@ -12,6 +21,8 @@ use { solana_sdk_ids::system_program::ID as SYSTEM_PROGRAM_ID, solana_signer::Signer, solana_transaction::Transaction, + spl_tlv_account_resolution::{account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList}, + spl_transfer_hook_interface::instruction::ExecuteInstruction, std::path::PathBuf, }; @@ -36,9 +47,10 @@ fn setup() -> (LiteSVM, Keypair) { (svm, admin_kp) } -#[test] -fn test() { - let (mut svm, admin_kp) = setup(); +/// Runs `init_config` then `init_mint` (with `admin_pk` as the mint's +/// `transfer_hook_authority`) and returns the resulting mint + meta-list +/// pubkeys, so tests that need a live mint don't have to repeat the setup. +fn setup_mint(svm: &mut LiteSVM, admin_kp: &Keypair) -> (Pubkey, Pubkey) { let admin_pk = admin_kp.pubkey(); let mint_kp = Keypair::new(); @@ -48,21 +60,13 @@ fn test() { let init_cfg_ix = abl_token::instruction::InitConfig {}; - let init_cfg_accounts = InitConfig { - payer: admin_pk, - config: config, - system_program: SYSTEM_PROGRAM_ID, - }; + let init_cfg_accounts = InitConfig { payer: admin_pk, config: config, system_program: SYSTEM_PROGRAM_ID }; let accs = init_cfg_accounts.to_account_metas(None); - let instruction = Instruction { - program_id: PROGRAM_ID, - accounts: accs, - data: init_cfg_ix.data(), - }; + let instruction = Instruction { program_id: PROGRAM_ID, accounts: accs, data: init_cfg_ix.data() }; let msg = Message::new(&[instruction], Some(&admin_pk)); - let tx = Transaction::new(&[&admin_kp], msg, svm.latest_blockhash()); + let tx = Transaction::new(&[admin_kp], msg, svm.latest_blockhash()); svm.send_transaction(tx).unwrap(); @@ -92,15 +96,195 @@ fn test() { let accs = init_mint_accounts.to_account_metas(None); + let instruction = Instruction { program_id: PROGRAM_ID, accounts: accs, data: data }; + let msg = Message::new(&[instruction], Some(&admin_pk)); + let tx = Transaction::new(&[admin_kp, &mint_kp], msg, svm.latest_blockhash()); + + svm.send_transaction(tx).unwrap(); + + (mint_pk, meta_list) +} + +#[test] +fn init_config_and_init_mint_succeed() { + let (mut svm, admin_kp) = setup(); + setup_mint(&mut svm, &admin_kp); +} + +#[test] +fn resize_meta_list_succeeds_and_is_idempotent() { + let (mut svm, admin_kp) = setup(); + let admin_pk = admin_kp.pubkey(); + let (mint_pk, meta_list) = setup_mint(&mut svm, &admin_kp); + + // Fresh mints already get the current (2-entry) layout, so this is the + // idempotent case: resizing to the same size and rewriting identical + // content must still succeed and leave a well-formed account behind. + let before = svm.get_account(&meta_list).unwrap().data; + assert_eq!(before.len(), abl_token::get_meta_list_size().unwrap()); + + let resize_ix = abl_token::instruction::ResizeMetaList {}; + let resize_accounts = ResizeMetaList { + payer: admin_pk, + mint: mint_pk, + extra_metas_account: meta_list, + system_program: SYSTEM_PROGRAM_ID, + token_program: TOKEN_22_PROGRAM_ID, + }; let instruction = Instruction { program_id: PROGRAM_ID, - accounts: accs, - data: data, + accounts: resize_accounts.to_account_metas(None), + data: resize_ix.data(), }; let msg = Message::new(&[instruction], Some(&admin_pk)); - let tx = Transaction::new(&[&admin_kp, &mint_kp], msg, svm.latest_blockhash()); + let tx = Transaction::new(&[&admin_kp], msg, svm.latest_blockhash()); + + svm.send_transaction(tx).unwrap(); + + let after = svm.get_account(&meta_list).unwrap().data; + assert_eq!(after.len(), abl_token::get_meta_list_size().unwrap()); + assert_eq!(after, before); +} + +#[test] +fn resize_meta_list_is_permissionless() { + // Deliberately permissionless: gating this on the mint's transfer-hook + // authority would permanently strand any mint whose authority was + // revoked, since the content written doesn't depend on who calls it. + let (mut svm, admin_kp) = setup(); + let (mint_pk, meta_list) = setup_mint(&mut svm, &admin_kp); + + let stranger_kp = Keypair::new(); + let stranger_pk = stranger_kp.pubkey(); + svm.airdrop(&stranger_pk, 10 * LAMPORTS_PER_SOL).unwrap(); + + let resize_ix = abl_token::instruction::ResizeMetaList {}; + let resize_accounts = ResizeMetaList { + payer: stranger_pk, + mint: mint_pk, + extra_metas_account: meta_list, + system_program: SYSTEM_PROGRAM_ID, + token_program: TOKEN_22_PROGRAM_ID, + }; + let instruction = Instruction { + program_id: PROGRAM_ID, + accounts: resize_accounts.to_account_metas(None), + data: resize_ix.data(), + }; + let msg = Message::new(&[instruction], Some(&stranger_pk)); + let tx = Transaction::new(&[&stranger_kp], msg, svm.latest_blockhash()); + + svm.send_transaction(tx).unwrap(); +} + +#[test] +fn resize_meta_list_rejects_a_mint_not_using_this_hook() { + let (mut svm, admin_kp) = setup(); + let admin_pk = admin_kp.pubkey(); + + // A mint whose TransferHook extension points at some other program. + let mint_kp = Keypair::new(); + let mint_pk = mint_kp.pubkey(); + let other_program = Pubkey::new_unique(); + + let space = ExtensionType::try_calculate_account_len::(&[ExtensionType::TransferHook]) + .unwrap(); + let rent = svm.minimum_balance_for_rent_exemption(space); + + let create_ix = create_account(&admin_pk, &mint_pk, rent, space as u64, &TOKEN_22_PROGRAM_ID); + let init_hook_ix = + transfer_hook::instruction::initialize(&TOKEN_22_PROGRAM_ID, &mint_pk, Some(admin_pk), Some(other_program)) + .unwrap(); + let init_mint_ix = initialize_mint2(&TOKEN_22_PROGRAM_ID, &mint_pk, &admin_pk, None, 6).unwrap(); + + let tx = Transaction::new( + &[&admin_kp, &mint_kp], + Message::new(&[create_ix, init_hook_ix, init_mint_ix], Some(&admin_pk)), + svm.latest_blockhash(), + ); + svm.send_transaction(tx).unwrap(); + + let meta_list = derive_meta_list(&mint_pk); + let resize_ix = abl_token::instruction::ResizeMetaList {}; + let resize_accounts = ResizeMetaList { + payer: admin_pk, + mint: mint_pk, + extra_metas_account: meta_list, + system_program: SYSTEM_PROGRAM_ID, + token_program: TOKEN_22_PROGRAM_ID, + }; + let instruction = Instruction { + program_id: PROGRAM_ID, + accounts: resize_accounts.to_account_metas(None), + data: resize_ix.data(), + }; + let msg = Message::new(&[instruction], Some(&admin_pk)); + let tx = Transaction::new(&[&admin_kp], msg, svm.latest_blockhash()); + + let res = svm.send_transaction(tx); + assert!(res.is_err(), "resizing a mint that isn't using this hook program must be rejected"); +} + +#[test] +fn resize_meta_list_migrates_a_mint_created_under_the_old_one_entry_layout() { + let (mut svm, admin_kp) = setup(); + let admin_pk = admin_kp.pubkey(); + let (mint_pk, meta_list) = setup_mint(&mut svm, &admin_kp); + + // Overwrite the freshly-created (already-correct, 2-entry) meta list with + // what a mint set up under the *old* program would actually have on + // chain: a single entry resolving only the destination wallet. This is + // the exact stale state Greptile flagged - upgrading the program alone + // doesn't rewrite already-initialized accounts. + let old_metas = vec![ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { bytes: b"ab_wallet".to_vec() }, + Seed::AccountData { account_index: 2, data_index: 32, length: 32 }, + ], + false, + false, + ) + .unwrap()]; + let old_size = ExtraAccountMetaList::size_of(old_metas.len()).unwrap(); + let mut old_data = vec![0u8; old_size]; + ExtraAccountMetaList::init::(&mut old_data, &old_metas).unwrap(); + + let current_account = svm.get_account(&meta_list).unwrap(); + svm.set_account( + meta_list, + Account { lamports: svm.minimum_balance_for_rent_exemption(old_size), data: old_data, ..current_account }, + ) + .unwrap(); + assert_eq!(svm.get_account(&meta_list).unwrap().data.len(), old_size); + + let resize_ix = abl_token::instruction::ResizeMetaList {}; + let resize_accounts = ResizeMetaList { + payer: admin_pk, + mint: mint_pk, + extra_metas_account: meta_list, + system_program: SYSTEM_PROGRAM_ID, + token_program: TOKEN_22_PROGRAM_ID, + }; + let instruction = Instruction { + program_id: PROGRAM_ID, + accounts: resize_accounts.to_account_metas(None), + data: resize_ix.data(), + }; + let msg = Message::new(&[instruction], Some(&admin_pk)); + let tx = Transaction::new(&[&admin_kp], msg, svm.latest_blockhash()); + svm.send_transaction(tx).unwrap(); + + let new_size = abl_token::get_meta_list_size().unwrap(); + let mut expected_data = vec![0u8; new_size]; + ExtraAccountMetaList::init::( + &mut expected_data, + &abl_token::get_extra_account_metas().unwrap(), + ) + .unwrap(); - let _res = svm.send_transaction(tx).unwrap(); + let migrated = svm.get_account(&meta_list).unwrap(); + assert_eq!(migrated.data.len(), new_size, "meta list must be resized to the current 2-entry layout"); + assert_eq!(migrated.data, expected_data, "migrated meta list must match a freshly-initialized one exactly"); } fn derive_config() -> Pubkey { diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx index 0f6d69d6a..1f503b595 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx @@ -83,10 +83,17 @@ export function useSendTokens() { if (!transferHook) throw new Error('bad token'); const extraMetas = getExtraAccountMetaAddress(mint, transferHook.programId); - const seeds = [Buffer.from('ab_wallet'), destination.toBuffer()]; - const abWallet = PublicKey.findProgramAddressSync(seeds, transferHook.programId)[0]; + // The hook checks both the sender and the receiver's allow/block + // status - the account order here must match the program's + // get_extra_account_metas() (source first, then destination). + const sourceSeeds = [Buffer.from('ab_wallet'), publicKey.toBuffer()]; + const sourceAbWallet = PublicKey.findProgramAddressSync(sourceSeeds, transferHook.programId)[0]; - ix3.keys.push({ pubkey: abWallet, isSigner: false, isWritable: false }); + const destinationSeeds = [Buffer.from('ab_wallet'), destination.toBuffer()]; + const destinationAbWallet = PublicKey.findProgramAddressSync(destinationSeeds, transferHook.programId)[0]; + + ix3.keys.push({ pubkey: sourceAbWallet, isSigner: false, isWritable: false }); + ix3.keys.push({ pubkey: destinationAbWallet, isSigner: false, isWritable: false }); ix3.keys.push({ pubkey: transferHook.programId, isSigner: false,