diff --git a/CLAUDE.md b/CLAUDE.md index acdad39..dfbbf49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,4 +117,4 @@ Audited by Cantina. See [audits/AUDIT_STATUS.md](audits/AUDIT_STATUS.md) for the - **No `mod.rs` business logic**: only module declarations and re-exports. - **PDA seeds co-located with state**: each state struct exposes its seed pattern; helpers live in `state/common.rs`. - **Codama attributes drive IDL**: keep `#[codama(...)]` macros in sync with Rust types — `just generate-idl && git diff` catches drift. -- **Token-2022 extension allowlist**: rejects ConfidentialTransfer, NonTransferable, PermanentDelegate, TransferHook, TransferFee, MintCloseAuthority, Pausable. +- **Token-2022 extension policy**: rejects only mints with a configured `TransferHook` (either the hook `authority` or `program_id` is set). Inert `TransferHook` (both unset and therefore permanently immutable) is allowed. diff --git a/docs/001-program-architecture.md b/docs/001-program-architecture.md index 7e7c47a..1f76d8e 100644 --- a/docs/001-program-architecture.md +++ b/docs/001-program-architecture.md @@ -502,3 +502,10 @@ Executes a transfer for a recurring delegation. 6. Update tracking 7. Execute transfer via SubscriptionAuthority 8. Emit `RecurringTransferEvent` via self-CPI + +## Token-2022 Extension Policy + +`Mint2022Account::check` rejects only mints with a configured `TransferHook` +(`authority` or `program_id` set). An inert `TransferHook` (both fields unset) is +allowed: with no hook authority, `update_transfer_hook` cannot be invoked, so the hook +stays permanently dormant. diff --git a/program/src/errors.rs b/program/src/errors.rs index 3fc6263..962b533 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -143,6 +143,7 @@ pub enum SubscriptionsError { ArithmeticUnderflow, #[error("Invalid account discriminator")] InvalidAccountDiscriminator, + /// Reserved for backwards compatibility. #[error("Mint has ConfidentialTransfer extension")] MintHasConfidentialTransfer, #[error("Mint has NonTransferable extension")] diff --git a/program/src/instructions/helpers/token.rs b/program/src/instructions/helpers/token.rs index fab4308..b0c1809 100644 --- a/program/src/instructions/helpers/token.rs +++ b/program/src/instructions/helpers/token.rs @@ -27,38 +27,42 @@ use crate::{ SubscriptionsError, }; -const EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT: u16 = 4; const EXTENSION_TYPE_TRANSFER_HOOK: u16 = 14; - +const TRANSFER_HOOK_EXTENSION_LEN: usize = 64; +const TLV_ENTRY_HEADER_LEN: usize = 4; const TLV_EXTENSIONS_START: usize = 166; -/// Validates that a Token-2022 mint does not contain any blocked extensions. -/// -/// Walks the TLV extension entries starting at byte 166 and rejects mints -/// that have ConfidentialTransfer or TransferHook extensions. +/// Validates that a Token-2022 mint does not contain any configured transfer hook. fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { let mut offset = TLV_EXTENSIONS_START; - while offset + 4 <= data.len() { + while offset + TLV_ENTRY_HEADER_LEN <= data.len() { let ext_type = u16::from_le_bytes([data[offset], data[offset + 1]]); let ext_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize; + let ext_data_start = offset + TLV_ENTRY_HEADER_LEN; + let ext_data_end = ext_data_start + .checked_add(ext_len) + .ok_or::(SubscriptionsError::InvalidToken2022MintAccountData.into())?; - // Type 0 = Uninitialized, signals end of TLV entries if ext_type == 0 { break; } - match ext_type { - EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT => { - return Err(SubscriptionsError::MintHasConfidentialTransfer.into()); + if ext_data_end > data.len() { + return Err(SubscriptionsError::InvalidToken2022MintAccountData.into()); + } + + if ext_type == EXTENSION_TYPE_TRANSFER_HOOK { + let extension_data = &data[ext_data_start..ext_data_end]; + if extension_data.len() != TRANSFER_HOOK_EXTENSION_LEN { + return Err(SubscriptionsError::InvalidToken2022MintAccountData.into()); } - EXTENSION_TYPE_TRANSFER_HOOK => { + if extension_data.iter().any(|byte| *byte != 0) { return Err(SubscriptionsError::MintHasTransferHook.into()); } - _ => {} } - offset += 4 + ext_len; + offset = ext_data_end; } Ok(()) @@ -182,8 +186,7 @@ impl TokenInit for TokenAccount { /// Validation for Token-2022 mint accounts. /// /// Checks ownership by the Token-2022 program, minimum data length, the -/// `0x01` discriminator at byte 165, and rejects blocked extensions via -/// [`validate_mint_extensions`]. +/// `0x01` discriminator at byte 165, and rejects configured transfer hooks. pub struct Mint2022Account; impl AccountCheck for Mint2022Account { diff --git a/tests/integration-tests/src/test_initialize_subscription_authority.rs b/tests/integration-tests/src/test_initialize_subscription_authority.rs index b46d293..7c29177 100644 --- a/tests/integration-tests/src/test_initialize_subscription_authority.rs +++ b/tests/integration-tests/src/test_initialize_subscription_authority.rs @@ -14,7 +14,8 @@ use crate::{ pda::get_subscription_authority_pda, utils::{ build_and_send_transaction, fetch_account, init_ata, init_aux_token_account, init_mint, init_wallet, - initialize_subscription_authority_action, initialize_subscription_authority_action_with_sponsor, setup, + initialize_subscription_authority_action, initialize_subscription_authority_action_with_sponsor, + set_transfer_hook_config, setup, }, }, AccountDiscriminator, SubscriptionAuthority, SubscriptionsError, @@ -109,7 +110,7 @@ fn initialize_subscription_authority_with_sponsor() { #[case::no_extensions(&[], None)] #[case::confidential_transfer( &[ExtensionType::ConfidentialTransferMint], - Some(SubscriptionsError::MintHasConfidentialTransfer) + None )] #[case::non_transferable( &[ExtensionType::NonTransferable], @@ -123,9 +124,9 @@ fn initialize_subscription_authority_with_sponsor() { &[ExtensionType::TransferFeeConfig], None )] -#[case::transfer_hook( +#[case::transfer_hook_unconfigured( &[ExtensionType::TransferHook], - Some(SubscriptionsError::MintHasTransferHook) + None )] #[case::pausable( &[ExtensionType::Pausable], @@ -135,13 +136,13 @@ fn initialize_subscription_authority_with_sponsor() { &[ExtensionType::MintCloseAuthority], None )] -#[case::multiple_blocked( +#[case::mixed_allowed( &[ExtensionType::TransferFeeConfig, ExtensionType::TransferHook], - Some(SubscriptionsError::MintHasTransferHook) + None )] -#[case::mixed_blocked( +#[case::mixed_allowed_confidential( &[ExtensionType::MintCloseAuthority, ExtensionType::ConfidentialTransferMint], - Some(SubscriptionsError::MintHasConfidentialTransfer) + None )] fn initialize_subscription_authority_token_2022( #[case] extensions: &[ExtensionType], @@ -177,6 +178,42 @@ fn initialize_subscription_authority_token_2022( } } +#[test] +fn initialize_subscription_authority_rejects_transfer_hook_with_program_id() { + let (litesvm, user) = &mut setup(); + + let mint = init_mint( + litesvm, + TOKEN_2022_PROGRAM_ID, + MINT_DECIMALS, + 1_000_000_000, + Some(user.pubkey()), + &[ExtensionType::TransferHook], + ); + set_transfer_hook_config(litesvm, mint, None, Some(Pubkey::new_unique())); + init_ata(litesvm, mint, user.pubkey(), 1_000_000); + + initialize_subscription_authority_action(litesvm, user, mint).0.assert_err(SubscriptionsError::MintHasTransferHook); +} + +#[test] +fn initialize_subscription_authority_rejects_mutable_transfer_hook() { + let (litesvm, user) = &mut setup(); + + let mint = init_mint( + litesvm, + TOKEN_2022_PROGRAM_ID, + MINT_DECIMALS, + 1_000_000_000, + Some(user.pubkey()), + &[ExtensionType::TransferHook], + ); + set_transfer_hook_config(litesvm, mint, Some(user.pubkey()), None); + init_ata(litesvm, mint, user.pubkey(), 1_000_000); + + initialize_subscription_authority_action(litesvm, user, mint).0.assert_err(SubscriptionsError::MintHasTransferHook); +} + #[test] fn wrong_token_program_returns_error() { let (litesvm, user) = &mut setup(); diff --git a/tests/integration-tests/src/test_transfer_fixed_delegation.rs b/tests/integration-tests/src/test_transfer_fixed_delegation.rs index 3fac111..e905871 100644 --- a/tests/integration-tests/src/test_transfer_fixed_delegation.rs +++ b/tests/integration-tests/src/test_transfer_fixed_delegation.rs @@ -110,6 +110,70 @@ fn test_fixed_transfer_token_2022_transfer_fee() { assert_eq!(remaining_amount, 40_000_000); } +#[test] +fn test_fixed_transfer_token_2022_confidential_transfer_public_balance() { + let (mut litesvm, alice) = setup(); + let bob = Keypair::new(); + litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap(); + + let mint = init_mint( + &mut litesvm, + TOKEN_2022_PROGRAM_ID, + MINT_DECIMALS, + 1_000_000_000, + Some(alice.pubkey()), + &[ExtensionType::ConfidentialTransferMint], + ); + let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); + let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0); + + initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok(); + + let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey()) + .fixed(50_000_000, current_ts() + days(1) as i64); + res.assert_ok(); + + TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda) + .amount(10_000_000) + .fixed() + .assert_ok(); + + assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000); + assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000); +} + +#[test] +fn test_fixed_transfer_token_2022_unconfigured_transfer_hook() { + let (mut litesvm, alice) = setup(); + let bob = Keypair::new(); + litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap(); + + let mint = init_mint( + &mut litesvm, + TOKEN_2022_PROGRAM_ID, + MINT_DECIMALS, + 1_000_000_000, + Some(alice.pubkey()), + &[ExtensionType::TransferHook], + ); + let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); + let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0); + + initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok(); + + let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey()) + .fixed(50_000_000, current_ts() + days(1) as i64); + res.assert_ok(); + + TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda) + .amount(10_000_000) + .fixed() + .assert_ok(); + + assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000); + assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000); +} + #[test] fn test_fixed_transfer_multiple_times() { let amount: u64 = 50_000_000; diff --git a/tests/integration-tests/src/test_transfer_recurring_delegation.rs b/tests/integration-tests/src/test_transfer_recurring_delegation.rs index f3d4cdc..6d785b7 100644 --- a/tests/integration-tests/src/test_transfer_recurring_delegation.rs +++ b/tests/integration-tests/src/test_transfer_recurring_delegation.rs @@ -4,7 +4,7 @@ use crate::{ state::{header::VERSION_OFFSET, RecurringDelegation}, tests::{ asserts::TransactionResultExt, - constants::{MINT_DECIMALS, PROGRAM_ID, TOKEN_PROGRAM_ID}, + constants::{MINT_DECIMALS, PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID}, idl, pda::get_subscription_authority_pda, utils::{ @@ -22,6 +22,7 @@ use solana_pubkey::Pubkey; use solana_signer::Signer; use solana_transaction_error::TransactionError::InstructionError; use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; +use spl_token_2022_interface::extension::ExtensionType; use spl_token_interface::instruction::TokenInstruction::{Approve, Revoke}; fn setup_recurring_delegation( @@ -795,3 +796,75 @@ fn test_recurring_transfer_past_drift_window() { .recurring(); result.assert_err(SubscriptionsError::DelegationExpired); } + +#[test] +fn test_recurring_transfer_token_2022_confidential_transfer_public_balance() { + let (mut litesvm, alice) = setup(); + let bob = Keypair::new(); + litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap(); + + let mint = init_mint( + &mut litesvm, + TOKEN_2022_PROGRAM_ID, + MINT_DECIMALS, + 1_000_000_000, + Some(alice.pubkey()), + &[ExtensionType::ConfidentialTransferMint], + ); + let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); + let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0); + + initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok(); + + let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey()).recurring( + 50_000_000, + hours(1), + current_ts(), + current_ts() + days(1) as i64, + ); + res.assert_ok(); + + TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda) + .amount(10_000_000) + .recurring() + .assert_ok(); + + assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000); + assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000); +} + +#[test] +fn test_recurring_transfer_token_2022_unconfigured_transfer_hook() { + let (mut litesvm, alice) = setup(); + let bob = Keypair::new(); + litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap(); + + let mint = init_mint( + &mut litesvm, + TOKEN_2022_PROGRAM_ID, + MINT_DECIMALS, + 1_000_000_000, + Some(alice.pubkey()), + &[ExtensionType::TransferHook], + ); + let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000); + let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0); + + initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok(); + + let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey()).recurring( + 50_000_000, + hours(1), + current_ts(), + current_ts() + days(1) as i64, + ); + res.assert_ok(); + + TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda) + .amount(10_000_000) + .recurring() + .assert_ok(); + + assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000); + assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000); +} diff --git a/tests/integration-tests/src/utils/test_helpers.rs b/tests/integration-tests/src/utils/test_helpers.rs index 92b4319..2f4a491 100644 --- a/tests/integration-tests/src/utils/test_helpers.rs +++ b/tests/integration-tests/src/utils/test_helpers.rs @@ -206,6 +206,22 @@ pub fn init_mint( mint } +pub fn set_transfer_hook_config( + litesvm: &mut LiteSVM, + mint: Pubkey, + authority: Option, + program_id: Option, +) { + let mut account = litesvm.get_account(&mint).unwrap(); + { + let mut state = StateWithExtensionsMut::::unpack(&mut account.data).unwrap(); + let extension = state.get_extension_mut::().unwrap(); + extension.authority = authority.try_into().unwrap(); + extension.program_id = program_id.try_into().unwrap(); + } + litesvm.set_account(mint, account).unwrap(); +} + pub fn init_ata(litesvm: &mut LiteSVM, mint: Pubkey, owner: Pubkey, amount: u64) -> Pubkey { let token_program = litesvm.get_account(&mint).unwrap().owner; let ata = get_associated_token_address_with_program_id(&owner, &mint, &token_program);