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 409e85d1f..9ea81f8f0 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 @@ -16,4 +16,7 @@ pub enum ABListError { #[msg("Mint is not configured to use this transfer hook program")] MintNotUsingThisHook, + + #[msg("Token account must have the ImmutableOwner extension")] + ImmutableOwnerRequired, } 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 676a12aba..39cdc0923 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 @@ -2,8 +2,9 @@ use anchor_lang::{prelude::*, solana_program::program::invoke, solana_program::s use anchor_spl::{ token_2022::Token2022, token_interface::{ - spl_token_metadata_interface::state::Field, token_metadata_initialize, token_metadata_update_field, Mint, - TokenMetadataInitialize, TokenMetadataUpdateField, + set_authority, spl_token_2022::instruction::AuthorityType, spl_token_metadata_interface::state::Field, + token_metadata_initialize, token_metadata_update_field, Mint, SetAuthority, TokenMetadataInitialize, + TokenMetadataUpdateField, }, }; @@ -101,6 +102,16 @@ impl InitMint<'_> { ExtraAccountMetaList::init::(&mut data, &metas) .map_err(|_| ProgramError::InvalidAccountData)?; + // the payer had to be mint authority to initialize the metadata; hand over now + if args.mint_authority != self.payer.key() { + let cpi_accounts = SetAuthority { + current_authority: self.payer.to_account_info(), + account_or_mint: self.mint.to_account_info(), + }; + let cpi_ctx = CpiContext::new(self.token_program.key(), cpi_accounts); + set_authority(cpi_ctx, AuthorityType::MintTokens, Some(args.mint_authority))?; + } + 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 19df07c61..6df077b5e 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 @@ -3,8 +3,11 @@ use std::str::FromStr; use anchor_lang::prelude::*; use anchor_spl::{ token_2022::spl_token_2022::{ - extension::{BaseStateWithExtensions, StateWithExtensions}, - state::Mint, + extension::{ + immutable_owner::ImmutableOwner, permanent_delegate::PermanentDelegate, BaseStateWithExtensions, + StateWithExtensions, + }, + state::{Account as TokenAccount, Mint}, }, token_interface::spl_token_metadata_interface::state::TokenMetadata, }; @@ -35,12 +38,29 @@ impl TxHook<'_> { let mint_data = mint_info.data.borrow(); let mint = StateWithExtensions::::unpack(&mint_data)?; + // The lists are keyed on the token-account owner, which can be + // changed without invoking the hook unless the owner is immutable. + Self::require_immutable_owner(&self.source_token_account)?; + Self::require_immutable_owner(&self.destination_token_account)?; + let metadata = mint.get_variable_len_extension::()?; let decoded_mode = Self::decode_metadata(&metadata)?; let source_wallet_mode = Self::decode_wallet_mode(&self.source_ab_wallet)?; let destination_wallet_mode = Self::decode_wallet_mode(&self.destination_ab_wallet)?; + let authority_is_permanent_delegate = mint + .get_extension::() + .ok() + .and_then(|extension| Option::::from(extension.delegate)) + .is_some_and(|delegate| delegate == self.owner_delegate.key()); + + decide(decoded_mode, source_wallet_mode, destination_wallet_mode, amount, authority_is_permanent_delegate) + } - decide(decoded_mode, source_wallet_mode, destination_wallet_mode, amount) + fn require_immutable_owner(account: &UncheckedAccount) -> Result<()> { + let data = account.data.borrow(); + let token_account = StateWithExtensions::::unpack(&data)?; + token_account.get_extension::().map_err(|_| ABListError::ImmutableOwnerRequired)?; + Ok(()) } fn decode_wallet_mode(account: &UncheckedAccount) -> Result { @@ -100,7 +120,10 @@ impl TxHook<'_> { /// /// 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. +/// mint's overall mode. This is checked first and applies to both sides. The +/// one exception is the mint's permanent delegate, which may still move +/// tokens *out* of a blocked wallet (clawback); the destination rules apply +/// to it like to anyone else. /// /// Beyond that, Allow/Threshold mode gate who may *receive* only, matching /// this program's documented semantics (see README): Force Allow requires @@ -111,8 +134,12 @@ fn decide( source_wallet_mode: DecodedWalletMode, destination_wallet_mode: DecodedWalletMode, amount: u64, + authority_is_permanent_delegate: bool, ) -> Result<()> { - if source_wallet_mode == DecodedWalletMode::Block || destination_wallet_mode == DecodedWalletMode::Block { + if destination_wallet_mode == DecodedWalletMode::Block { + return Err(ABListError::WalletBlocked.into()); + } + if source_wallet_mode == DecodedWalletMode::Block && !authority_is_permanent_delegate { return Err(ABListError::WalletBlocked.into()); } @@ -154,7 +181,7 @@ mod tests { // 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); + let result = decide(mint_mode_clone(&mint_mode), DecodedWalletMode::Block, destination_mode, 0, false); assert!( result.is_err(), "expected a blocked source to be rejected regardless of mint mode / destination status" @@ -168,51 +195,84 @@ mod tests { // 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" - ); + for is_permanent_delegate in [false, true] { + let result = decide( + mint_mode_clone(&mint_mode), + mint_mode_clone_wallet(&source_mode), + DecodedWalletMode::Block, + 0, + is_permanent_delegate, + ); + assert!( + result.is_err(), + "expected a blocked destination to be rejected regardless of mint mode / source status / authority" + ); + } } } } + #[test] + fn permanent_delegate_may_claw_back_from_a_blocked_source() { + for mint_mode in [DecodedMintMode::Block, DecodedMintMode::Threshold(100)] { + let result = decide(mint_mode, DecodedWalletMode::Block, DecodedWalletMode::None, 0, true); + assert!( + result.is_ok(), + "expected the permanent delegate to be able to move tokens out of a blocked wallet" + ); + } + let result = decide(DecodedMintMode::Allow, DecodedWalletMode::Block, DecodedWalletMode::Allow, 0, true); + assert!(result.is_ok()); + } + + #[test] + fn permanent_delegate_is_still_subject_to_destination_rules() { + let result = decide(DecodedMintMode::Allow, DecodedWalletMode::Block, DecodedWalletMode::None, 0, true); + assert!(result.is_err(), "Allow mode must still require the destination to be allowed"); + let result = + decide(DecodedMintMode::Threshold(100), DecodedWalletMode::Block, DecodedWalletMode::None, 100, true); + assert!(result.is_err(), "Threshold mode must still gate large transfers to unlisted destinations"); + } + #[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); + let result = decide(DecodedMintMode::Allow, DecodedWalletMode::None, DecodedWalletMode::Allow, 0, false); assert!(result.is_ok()); } #[test] fn allow_mode_rejects_an_unlisted_destination() { - let result = decide(DecodedMintMode::Allow, DecodedWalletMode::None, DecodedWalletMode::None, 0); + let result = decide(DecodedMintMode::Allow, DecodedWalletMode::None, DecodedWalletMode::None, 0, false); assert!(result.is_err()); } #[test] fn block_mode_allows_unlisted_wallets() { - let result = decide(DecodedMintMode::Block, DecodedWalletMode::None, DecodedWalletMode::None, 0); + let result = decide(DecodedMintMode::Block, DecodedWalletMode::None, DecodedWalletMode::None, 0, false); 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); + let result = + decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::None, 50, false); 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); + let result = + decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::None, 100, false); 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); + let result = + decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::Allow, 100, false); assert!(result.is_ok()); } @@ -223,4 +283,12 @@ mod tests { DecodedMintMode::Threshold(t) => DecodedMintMode::Threshold(*t), } } + + fn mint_mode_clone_wallet(mode: &DecodedWalletMode) -> DecodedWalletMode { + match mode { + DecodedWalletMode::Allow => DecodedWalletMode::Allow, + DecodedWalletMode::Block => DecodedWalletMode::Block, + DecodedWalletMode::None => DecodedWalletMode::None, + } + } } 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 e2e0171a3..7a20e1d9d 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,19 +1,27 @@ use { - abl_token::{accounts::InitConfig, accounts::InitMint, accounts::ResizeMetaList, instructions::InitMintArgs, Mode}, + abl_token::{ + accounts::{InitConfig, InitMint, InitWallet, ResizeMetaList}, + instructions::{InitMintArgs, InitWalletArgs}, + Mode, + }, anchor_lang::solana_program::system_instruction::create_account, anchor_lang::InstructionData, anchor_lang::ToAccountMetas, anchor_spl::token_2022::{ spl_token_2022::{ self, - extension::{transfer_hook, ExtensionType}, - instruction::initialize_mint2, + extension::{transfer_hook, ExtensionType, StateWithExtensions}, + instruction::{ + initialize_account3, initialize_immutable_owner, initialize_mint2, mint_to, set_authority, + transfer_checked, AuthorityType, + }, + state::{Account as TokenAccount, Mint}, }, ID as TOKEN_22_PROGRAM_ID, }, - litesvm::LiteSVM, + litesvm::{types::FailedTransactionMetadata, types::TransactionMetadata, LiteSVM}, solana_account::Account, - solana_instruction::Instruction, + solana_instruction::{AccountMeta, Instruction}, solana_keypair::Keypair, solana_message::Message, solana_native_token::LAMPORTS_PER_SOL, @@ -27,6 +35,7 @@ use { }; const PROGRAM_ID: Pubkey = abl_token::ID_CONST; +const DECIMALS: u8 = 6; fn setup() -> (LiteSVM, Keypair) { let mut svm = LiteSVM::new(); @@ -47,34 +56,81 @@ fn setup() -> (LiteSVM, Keypair) { (svm, admin_kp) } -/// 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) { +/// Signs with every keypair in `signers` (the first one pays) and sends. +fn send( + svm: &mut LiteSVM, + signers: &[&Keypair], + instructions: &[Instruction], +) -> Result { + let msg = Message::new(instructions, Some(&signers[0].pubkey())); + let tx = Transaction::new(signers, msg, svm.latest_blockhash()); + svm.send_transaction(tx) +} + +fn init_config(svm: &mut LiteSVM, admin_kp: &Keypair) { let admin_pk = admin_kp.pubkey(); + let init_cfg_accounts = InitConfig { payer: admin_pk, config: derive_config(), system_program: SYSTEM_PROGRAM_ID }; + let instruction = Instruction { + program_id: PROGRAM_ID, + accounts: init_cfg_accounts.to_account_metas(None), + data: abl_token::instruction::InitConfig {}.data(), + }; + send(svm, &[admin_kp], &[instruction]).unwrap(); +} - let mint_kp = Keypair::new(); +/// Runs `init_mint` for `mint_kp` with `args` and returns the meta-list pubkey. +fn init_mint(svm: &mut LiteSVM, admin_kp: &Keypair, mint_kp: &Keypair, args: InitMintArgs) -> Pubkey { + let admin_pk = admin_kp.pubkey(); let mint_pk = mint_kp.pubkey(); - let config = derive_config(); let meta_list = derive_meta_list(&mint_pk); - let init_cfg_ix = abl_token::instruction::InitConfig {}; + let init_mint_accounts = InitMint { + 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: init_mint_accounts.to_account_metas(None), + data: abl_token::instruction::InitMint { args }.data(), + }; + send(svm, &[admin_kp, mint_kp], &[instruction]).unwrap(); - let init_cfg_accounts = InitConfig { payer: admin_pk, config: config, system_program: SYSTEM_PROGRAM_ID }; + meta_list +} - let accs = init_cfg_accounts.to_account_metas(None); +fn mint_args(mode: Mode, threshold: u64, authority: Pubkey, permanent_delegate: Pubkey) -> InitMintArgs { + InitMintArgs { + name: "Test".to_string(), + symbol: "TEST".to_string(), + uri: "https://test.com".to_string(), + decimals: DECIMALS, + mint_authority: authority, + freeze_authority: authority, + permanent_delegate, + transfer_hook_authority: authority, + mode, + threshold, + } +} - 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()); +/// 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(); + let mint_pk = mint_kp.pubkey(); - svm.send_transaction(tx).unwrap(); + init_config(svm, admin_kp); - let args: InitMintArgs = InitMintArgs { + let args = InitMintArgs { name: "Test".to_string(), symbol: "TEST".to_string(), uri: "https://test.com".to_string(), - decimals: 6, + decimals: DECIMALS, mint_authority: mint_pk, freeze_authority: mint_pk, permanent_delegate: mint_pk, @@ -82,27 +138,129 @@ fn setup_mint(svm: &mut LiteSVM, admin_kp: &Keypair) -> (Pubkey, Pubkey) { mode: Mode::Mixed, threshold: 100000, }; - let init_mint_ix = abl_token::instruction::InitMint { args: args }; + let meta_list = init_mint(svm, admin_kp, &mint_kp, args); + + (mint_pk, meta_list) +} - let data = init_mint_ix.data(); +/// A Block-mode mint (everyone may transact unless explicitly blocked) with +/// `admin_kp` as mint authority and `permanent_delegate` as the mint's +/// permanent delegate. Returns the mint pubkey. +fn setup_block_mode_mint(svm: &mut LiteSVM, admin_kp: &Keypair, permanent_delegate: Pubkey) -> Pubkey { + let mint_kp = Keypair::new(); + init_config(svm, admin_kp); + init_mint(svm, admin_kp, &mint_kp, mint_args(Mode::Block, 0, admin_kp.pubkey(), permanent_delegate)); + mint_kp.pubkey() +} - let init_mint_accounts = InitMint { - payer: admin_pk, - mint: mint_pk, - extra_metas_account: meta_list, +fn init_wallet(svm: &mut LiteSVM, admin_kp: &Keypair, wallet: Pubkey, allowed: bool) { + let accounts = InitWallet { + authority: admin_kp.pubkey(), + config: derive_config(), + wallet, + ab_wallet: derive_ab_wallet(&wallet), system_program: SYSTEM_PROGRAM_ID, - token_program: TOKEN_22_PROGRAM_ID, }; + let instruction = Instruction { + program_id: PROGRAM_ID, + accounts: accounts.to_account_metas(None), + data: abl_token::instruction::InitWallet { args: InitWalletArgs { allowed } }.data(), + }; + send(svm, &[admin_kp], &[instruction]).unwrap(); +} - let accs = init_mint_accounts.to_account_metas(None); +/// Creates a non-associated Token-2022 account for `owner`, optionally with +/// the `ImmutableOwner` extension (which every ATA carries, but which plain +/// token accounts only get when explicitly initialized). +fn create_token_account( + svm: &mut LiteSVM, + payer_kp: &Keypair, + mint: &Pubkey, + owner: &Pubkey, + immutable_owner: bool, +) -> Pubkey { + let account_kp = Keypair::new(); + let account_pk = account_kp.pubkey(); + + let mut extensions = vec![ExtensionType::TransferHookAccount]; + if immutable_owner { + extensions.push(ExtensionType::ImmutableOwner); + } + let space = ExtensionType::try_calculate_account_len::(&extensions).unwrap(); + let rent = svm.minimum_balance_for_rent_exemption(space); - 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()); + let mut instructions = + vec![create_account(&payer_kp.pubkey(), &account_pk, rent, space as u64, &TOKEN_22_PROGRAM_ID)]; + if immutable_owner { + instructions.push(initialize_immutable_owner(&TOKEN_22_PROGRAM_ID, &account_pk).unwrap()); + } + instructions.push(initialize_account3(&TOKEN_22_PROGRAM_ID, &account_pk, mint, owner).unwrap()); + send(svm, &[payer_kp, &account_kp], &instructions).unwrap(); - svm.send_transaction(tx).unwrap(); + account_pk +} - (mint_pk, meta_list) +fn mint_tokens(svm: &mut LiteSVM, authority_kp: &Keypair, mint: &Pubkey, account: &Pubkey, amount: u64) { + let instruction = mint_to(&TOKEN_22_PROGRAM_ID, mint, account, &authority_kp.pubkey(), &[], amount).unwrap(); + send(svm, &[authority_kp], &[instruction]).unwrap(); +} + +fn token_account(svm: &LiteSVM, account: &Pubkey) -> TokenAccount { + let data = svm.get_account(account).unwrap().data; + StateWithExtensions::::unpack(&data).unwrap().base +} + +fn token_balance(svm: &LiteSVM, account: &Pubkey) -> u64 { + token_account(svm, account).amount +} + +/// A `TransferChecked` signed by `authority_kp`, carrying the extra accounts +/// the transfer hook resolves: the hook program, the mint's meta list and the +/// `ab_wallet` PDA of each side's token-account owner. +fn hooked_transfer( + svm: &mut LiteSVM, + authority_kp: &Keypair, + mint: &Pubkey, + source: &Pubkey, + destination: &Pubkey, + amount: u64, +) -> Result { + let source_owner = token_account(svm, source).owner; + let destination_owner = token_account(svm, destination).owner; + + let mut instruction = transfer_checked( + &TOKEN_22_PROGRAM_ID, + source, + mint, + destination, + &authority_kp.pubkey(), + &[], + amount, + DECIMALS, + ) + .unwrap(); + instruction.accounts.extend([ + AccountMeta::new_readonly(PROGRAM_ID, false), + AccountMeta::new_readonly(derive_meta_list(mint), false), + AccountMeta::new_readonly(derive_ab_wallet(&source_owner), false), + AccountMeta::new_readonly(derive_ab_wallet(&destination_owner), false), + ]); + + send(svm, &[authority_kp], &[instruction]) +} + +fn assert_hook_error(failure: &FailedTransactionMetadata, error_name: &str) { + assert!( + failure.meta.logs.iter().any(|log| log.contains(error_name)), + "expected the transfer to fail with {error_name}, got: {:?}", + failure.meta.logs + ); +} + +fn funded_keypair(svm: &mut LiteSVM) -> Keypair { + let kp = Keypair::new(); + svm.airdrop(&kp.pubkey(), 10 * LAMPORTS_PER_SOL).unwrap(); + kp } #[test] @@ -111,6 +269,179 @@ fn init_config_and_init_mint_succeed() { setup_mint(&mut svm, &admin_kp); } +#[test] +fn init_mint_honours_the_mint_authority_argument() { + let (mut svm, admin_kp) = setup(); + init_config(&mut svm, &admin_kp); + + let mint_kp = Keypair::new(); + let mint_authority = Pubkey::new_unique(); + let mut args = mint_args(Mode::Block, 0, admin_kp.pubkey(), admin_kp.pubkey()); + args.mint_authority = mint_authority; + init_mint(&mut svm, &admin_kp, &mint_kp, args); + + let data = svm.get_account(&mint_kp.pubkey()).unwrap().data; + let mint = StateWithExtensions::::unpack(&data).unwrap(); + assert_eq!( + Option::::from(mint.base.mint_authority), + Some(mint_authority), + "the mint authority must be the one passed in InitMintArgs, not the payer" + ); +} + +#[test] +fn unlisted_wallets_can_transfer_in_block_mode() { + let (mut svm, admin_kp) = setup(); + let mint = setup_block_mode_mint(&mut svm, &admin_kp, admin_kp.pubkey()); + + let sender_kp = funded_keypair(&mut svm); + let source = create_token_account(&mut svm, &admin_kp, &mint, &sender_kp.pubkey(), true); + let destination = create_token_account(&mut svm, &admin_kp, &mint, &Pubkey::new_unique(), true); + mint_tokens(&mut svm, &admin_kp, &mint, &source, 1_000); + + hooked_transfer(&mut svm, &sender_kp, &mint, &source, &destination, 400).unwrap(); + + assert_eq!(token_balance(&svm, &source), 600); + assert_eq!(token_balance(&svm, &destination), 400); +} + +#[test] +fn blocked_wallet_cannot_send() { + let (mut svm, admin_kp) = setup(); + let mint = setup_block_mode_mint(&mut svm, &admin_kp, admin_kp.pubkey()); + + let blocked_kp = funded_keypair(&mut svm); + init_wallet(&mut svm, &admin_kp, blocked_kp.pubkey(), false); + + let source = create_token_account(&mut svm, &admin_kp, &mint, &blocked_kp.pubkey(), true); + let destination = create_token_account(&mut svm, &admin_kp, &mint, &Pubkey::new_unique(), true); + mint_tokens(&mut svm, &admin_kp, &mint, &source, 1_000); + + let failure = hooked_transfer(&mut svm, &blocked_kp, &mint, &source, &destination, 400) + .expect_err("a blocked wallet must not be able to send"); + assert_hook_error(&failure, "WalletBlocked"); + + assert_eq!(token_balance(&svm, &source), 1_000); + assert_eq!(token_balance(&svm, &destination), 0); +} + +#[test] +fn hook_rejects_a_source_account_without_immutable_owner() { + let (mut svm, admin_kp) = setup(); + let mint = setup_block_mode_mint(&mut svm, &admin_kp, admin_kp.pubkey()); + + let blocked_kp = funded_keypair(&mut svm); + init_wallet(&mut svm, &admin_kp, blocked_kp.pubkey(), false); + + // A plain (non-ATA) token account without ImmutableOwner, funded while + // owned by the blocked wallet. + let source = create_token_account(&mut svm, &admin_kp, &mint, &blocked_kp.pubkey(), false); + let destination = create_token_account(&mut svm, &admin_kp, &mint, &Pubkey::new_unique(), true); + mint_tokens(&mut svm, &admin_kp, &mint, &source, 1_000); + + // SetAuthority(AccountOwner) never invokes the transfer hook, so the + // blocked wallet hands the whole account to a fresh, unlisted wallet... + let fresh_kp = funded_keypair(&mut svm); + let reassign = set_authority( + &TOKEN_22_PROGRAM_ID, + &source, + Some(&fresh_kp.pubkey()), + AuthorityType::AccountOwner, + &blocked_kp.pubkey(), + &[], + ) + .unwrap(); + send(&mut svm, &[&blocked_kp], &[reassign]).unwrap(); + assert_eq!(token_account(&svm, &source).owner, fresh_kp.pubkey()); + + // ...and the fresh wallet sends the blocked wallet's tokens out. + let failure = hooked_transfer(&mut svm, &fresh_kp, &mint, &source, &destination, 400) + .expect_err("a source token account without ImmutableOwner must be rejected"); + assert_hook_error(&failure, "ImmutableOwnerRequired"); + + assert_eq!(token_balance(&svm, &source), 1_000); + assert_eq!(token_balance(&svm, &destination), 0); +} + +#[test] +fn hook_rejects_a_destination_account_without_immutable_owner() { + let (mut svm, admin_kp) = setup(); + let mint = setup_block_mode_mint(&mut svm, &admin_kp, admin_kp.pubkey()); + + let blocked_kp = funded_keypair(&mut svm); + init_wallet(&mut svm, &admin_kp, blocked_kp.pubkey(), false); + + let sender_kp = funded_keypair(&mut svm); + let source = create_token_account(&mut svm, &admin_kp, &mint, &sender_kp.pubkey(), true); + mint_tokens(&mut svm, &admin_kp, &mint, &source, 1_000); + + // The destination is owned by an unlisted wallet at transfer time... + let mule_kp = funded_keypair(&mut svm); + let destination = create_token_account(&mut svm, &admin_kp, &mint, &mule_kp.pubkey(), false); + + let failure = hooked_transfer(&mut svm, &sender_kp, &mint, &source, &destination, 400) + .expect_err("a destination token account without ImmutableOwner must be rejected"); + assert_hook_error(&failure, "ImmutableOwnerRequired"); + + assert_eq!(token_balance(&svm, &source), 1_000); + assert_eq!(token_balance(&svm, &destination), 0); + + // ...because otherwise it could be handed to the blocked wallet afterwards + // without the hook ever seeing the transfer. + let reassign = set_authority( + &TOKEN_22_PROGRAM_ID, + &destination, + Some(&blocked_kp.pubkey()), + AuthorityType::AccountOwner, + &mule_kp.pubkey(), + &[], + ) + .unwrap(); + send(&mut svm, &[&mule_kp], &[reassign]).unwrap(); + assert_eq!(token_account(&svm, &destination).owner, blocked_kp.pubkey()); +} + +#[test] +fn permanent_delegate_can_claw_back_from_a_blocked_wallet() { + let (mut svm, admin_kp) = setup(); + let delegate_kp = funded_keypair(&mut svm); + let mint = setup_block_mode_mint(&mut svm, &admin_kp, delegate_kp.pubkey()); + + let blocked_kp = funded_keypair(&mut svm); + init_wallet(&mut svm, &admin_kp, blocked_kp.pubkey(), false); + + let source = create_token_account(&mut svm, &admin_kp, &mint, &blocked_kp.pubkey(), true); + let treasury = create_token_account(&mut svm, &admin_kp, &mint, &admin_kp.pubkey(), true); + mint_tokens(&mut svm, &admin_kp, &mint, &source, 1_000); + + hooked_transfer(&mut svm, &delegate_kp, &mint, &source, &treasury, 1_000) + .expect("the permanent delegate must be able to claw back from a blocked wallet"); + + assert_eq!(token_balance(&svm, &source), 0); + assert_eq!(token_balance(&svm, &treasury), 1_000); +} + +#[test] +fn permanent_delegate_cannot_send_to_a_blocked_wallet() { + let (mut svm, admin_kp) = setup(); + let delegate_kp = funded_keypair(&mut svm); + let mint = setup_block_mode_mint(&mut svm, &admin_kp, delegate_kp.pubkey()); + + let blocked_kp = funded_keypair(&mut svm); + init_wallet(&mut svm, &admin_kp, blocked_kp.pubkey(), false); + + let source = create_token_account(&mut svm, &admin_kp, &mint, &Pubkey::new_unique(), true); + let destination = create_token_account(&mut svm, &admin_kp, &mint, &blocked_kp.pubkey(), true); + mint_tokens(&mut svm, &admin_kp, &mint, &source, 1_000); + + let failure = hooked_transfer(&mut svm, &delegate_kp, &mint, &source, &destination, 400) + .expect_err("destination rules still apply to the permanent delegate"); + assert_hook_error(&failure, "WalletBlocked"); + + assert_eq!(token_balance(&svm, &source), 1_000); + assert_eq!(token_balance(&svm, &destination), 0); +} + #[test] fn resize_meta_list_succeeds_and_is_idempotent() { let (mut svm, admin_kp) = setup(); @@ -296,3 +627,8 @@ fn derive_meta_list(mint: &Pubkey) -> Pubkey { let seeds = &[b"extra-account-metas", mint.as_ref()]; Pubkey::find_program_address(seeds, &PROGRAM_ID).0 } + +fn derive_ab_wallet(wallet: &Pubkey) -> Pubkey { + let seeds = &[b"ab_wallet", wallet.as_ref()]; + Pubkey::find_program_address(seeds, &PROGRAM_ID).0 +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/idl/abl_token.json b/tokens/token-2022/transfer-hook/allow-block-list-token/idl/abl_token.json index 6df727123..c1ecead85 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/idl/abl_token.json +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/idl/abl_token.json @@ -564,6 +564,11 @@ "code": 6004, "name": "MintNotUsingThisHook", "msg": "Mint is not configured to use this transfer hook program" + }, + { + "code": 6005, + "name": "ImmutableOwnerRequired", + "msg": "Token account must have the ImmutableOwner extension" } ], "types": [ diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/generated/errors/ablToken.ts b/tokens/token-2022/transfer-hook/allow-block-list-token/src/generated/errors/ablToken.ts index 14f1e276e..8887b892d 100644 --- a/tokens/token-2022/transfer-hook/allow-block-list-token/src/generated/errors/ablToken.ts +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/generated/errors/ablToken.ts @@ -24,9 +24,12 @@ export const ABL_TOKEN_ERROR__AMOUNT_NOT_ALLOWED = 0x1772; // 6002 export const ABL_TOKEN_ERROR__WALLET_BLOCKED = 0x1773; // 6003 /** MintNotUsingThisHook: Mint is not configured to use this transfer hook program */ export const ABL_TOKEN_ERROR__MINT_NOT_USING_THIS_HOOK = 0x1774; // 6004 +/** ImmutableOwnerRequired: Token account must have the ImmutableOwner extension */ +export const ABL_TOKEN_ERROR__IMMUTABLE_OWNER_REQUIRED = 0x1775; // 6005 export type AblTokenError = | typeof ABL_TOKEN_ERROR__AMOUNT_NOT_ALLOWED + | typeof ABL_TOKEN_ERROR__IMMUTABLE_OWNER_REQUIRED | typeof ABL_TOKEN_ERROR__INVALID_METADATA | typeof ABL_TOKEN_ERROR__MINT_NOT_USING_THIS_HOOK | typeof ABL_TOKEN_ERROR__WALLET_BLOCKED @@ -36,6 +39,7 @@ let ablTokenErrorMessages: Record | undefined; if (process.env['NODE_ENV'] !== 'production') { ablTokenErrorMessages = { [ABL_TOKEN_ERROR__AMOUNT_NOT_ALLOWED]: `Amount not allowed`, + [ABL_TOKEN_ERROR__IMMUTABLE_OWNER_REQUIRED]: `Token account must have the ImmutableOwner extension`, [ABL_TOKEN_ERROR__INVALID_METADATA]: `Invalid metadata`, [ABL_TOKEN_ERROR__MINT_NOT_USING_THIS_HOOK]: `Mint is not configured to use this transfer hook program`, [ABL_TOKEN_ERROR__WALLET_BLOCKED]: `Wallet blocked`,