From 60387632c7de5fd0e7c0cd25a57384187c287c7a Mon Sep 17 00:00:00 2001 From: Aung Nanda Oo Date: Wed, 5 Aug 2026 00:25:30 -0700 Subject: [PATCH 1/2] fix(abl-token): block wallets from sending, not just receiving get_extra_account_metas() only ever configured one extra account for the transfer hook's Execute call, resolved from the destination token account's owner. tx_hook() never saw the sender's ABWallet record, so a wallet marked allowed: false could still send its full balance out to any unlisted destination in every mint mode (Allow/Block/Threshold) - exactly the case the WalletBlocked error and admin UI's "Blocked" badge imply is prevented. Add a second extra account for the source token account's owner and extend the decision matrix so an explicitly blocked wallet is rejected on either side, while leaving Allow/Threshold mode's documented "who can receive" semantics untouched. The decision logic is pulled into a standalone decide() function with unit tests covering both sides of the block check plus the existing mode semantics, since this program has no integration-test harness that exercises tx_hook via a real hooked transfer. Also fixes the frontend's manual extra-account construction in useSendTokens() to push both the source and destination ab_wallet PDAs in the same order the program now expects. --- .../abl-token/src/instructions/tx_hook.rs | 149 +++++++++++++++--- .../anchor/programs/abl-token/src/utils.rs | 19 ++- .../account/account-data-access.tsx | 13 +- 3 files changed, 155 insertions(+), 26 deletions(-) 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/utils.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs index 7ffc952ad..cf36c661d 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 @@ -7,12 +7,27 @@ use spl_tlv_account_resolution::{ use crate::AB_WALLET_SEED; pub fn get_meta_list_size() -> Result { - Ok(ExtraAccountMetaList::size_of(1).map_err(|_| ProgramError::InvalidArgument)?) + Ok(ExtraAccountMetaList::size_of(2).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 { 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, From def8e0af287bff5c59a88e79585b3f589c3e8dbe Mon Sep 17 00:00:00 2001 From: Aung Nanda Oo Date: Wed, 5 Aug 2026 00:40:32 -0700 Subject: [PATCH 2/2] fix(abl-token): add resize_meta_list to migrate existing mints to the new layout Greptile review on PR #672 flagged that extra_metas_account is a fixed-size PDA created once by init_mint/attach_to_mint: mints created before the sender-side check was added are left with the old, undersized (one-entry) account, so their transfers start failing the hook's account-count check after the program upgrades - the fix has no effect for them without a migration path. Add resize_meta_list, which reallocates extra_metas_account to the current get_meta_list_size() and rewrites its contents via ExtraAccountMetaList::update (not ::init, which only handles the account's first-ever write). Authorization reuses attach_to_mint's existing pattern: a no-op transfer_hook_update CPI back to Token-2022, which only succeeds if the caller is the mint's actual transfer-hook authority - so this instruction needs no authority table of its own. Tested against a litesvm account manually seeded with the old one-entry TLV layout to confirm the migration produces byte-identical output to a fresh init_mint, plus the idempotent (already-current-size) case and rejection of a non-authority caller. --- .../anchor/programs/abl-token/Cargo.toml | 1 + .../abl-token/src/instructions/mod.rs | 2 + .../src/instructions/resize_meta_list.rs | 79 +++++++++ .../anchor/programs/abl-token/src/lib.rs | 4 + .../anchor/programs/abl-token/tests/test.rs | 163 +++++++++++++++++- 5 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/resize_meta_list.rs 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/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..34cb58d9c --- /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,79 @@ +use anchor_lang::{ + prelude::*, solana_program::program::invoke, solana_program::system_instruction::transfer, +}; +use anchor_spl::{ + token_2022::Token2022, + token_interface::{transfer_hook_update, Mint, TransferHookUpdate}, +}; + +use spl_tlv_account_resolution::state::ExtraAccountMetaList; +use spl_transfer_hook_interface::instruction::ExecuteInstruction; + +use crate::{get_extra_account_metas, get_meta_list_size, 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. +/// +/// This exists because `extra_metas_account` is a fixed-size PDA created once +/// by `init_mint`/`attach_to_mint`: if the program's extra-account list is +/// ever extended (e.g. to add the source-wallet check), mints that were set +/// up under the old layout are left with a stale, undersized account and +/// their transfers start failing the hook's account-count check. Any mint +/// authority can call this to bring an existing mint's extra-metas account +/// back in sync after such an upgrade. +#[derive(Accounts)] +pub struct ResizeMetaList<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + #[account(mut, 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<()> { + // Re-setting the transfer hook to itself has no effect on the mint, + // but the CPI only succeeds if `payer` is the mint's current + // transfer-hook authority - the same check `attach_to_mint` relies + // on, reused here so this instruction can't be called by anyone + // other than whoever is already trusted to configure this mint's hook. + let tx_hook_accs = TransferHookUpdate { + token_program_id: self.token_program.to_account_info(), + mint: self.mint.to_account_info(), + authority: self.payer.to_account_info(), + }; + let ctx = CpiContext::new(self.token_program.key(), tx_hook_accs); + transfer_hook_update(ctx, Some(crate::ID_CONST))?; + + 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/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/tests/test.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs index 67d68661e..12ddc3d5a 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,13 @@ use { - abl_token::{accounts::InitConfig, accounts::InitMint, instructions::InitMintArgs, Mode}, + abl_token::{ + accounts::InitConfig, accounts::InitMint, accounts::ResizeMetaList, + instructions::InitMintArgs, Mode, + }, anchor_lang::InstructionData, anchor_lang::ToAccountMetas, anchor_spl::token_2022::ID as TOKEN_22_PROGRAM_ID, litesvm::LiteSVM, + solana_account::Account, solana_instruction::Instruction, solana_keypair::Keypair, solana_message::Message, @@ -12,6 +16,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 +42,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(); @@ -62,7 +69,7 @@ fn test() { 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(); @@ -98,9 +105,151 @@ fn test() { data: 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, &mint_kp], msg, svm.latest_blockhash()); + + svm.send_transaction(tx).unwrap(); + + (mint_pk, meta_list) +} + +#[test] +fn test() { + let (mut svm, admin_kp) = setup(); + setup_mint(&mut svm, &admin_kp); +} + +#[test] +fn resize_meta_list_succeeds_for_the_mints_transfer_hook_authority() { + 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: 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 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_rejects_a_non_authority_signer() { + let (mut svm, admin_kp) = setup(); + let (mint_pk, meta_list) = setup_mint(&mut svm, &admin_kp); + + let attacker_kp = Keypair::new(); + let attacker_pk = attacker_kp.pubkey(); + svm.airdrop(&attacker_pk, 10 * LAMPORTS_PER_SOL).unwrap(); + + // Anyone can pay for the resize, but the CPI back into Token-2022's + // `update_transfer_hook` only succeeds if the signer is the mint's real + // transfer-hook authority (`admin_pk`, not `attacker_pk`) - that's what + // gates this instruction, since it has no authority table of its own. + let resize_ix = abl_token::instruction::ResizeMetaList {}; + let resize_accounts = ResizeMetaList { + payer: attacker_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(&attacker_pk)); + let tx = Transaction::new(&[&attacker_kp], msg, svm.latest_blockhash()); + + let res = svm.send_transaction(tx); + assert!(res.is_err(), "a non-authority signer must not be able to resize another mint's meta list"); +} + +#[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 {