From af9d2959e2b8adfef19720f76aaa7eafa2235513 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 14:30:22 -0400 Subject: [PATCH 1/6] feat(program): allow confidential transfer mints --- program/src/instructions/helpers/token.rs | 13 ++------ .../test_initialize_subscription_authority.rs | 6 ++-- .../src/test_transfer_fixed_delegation.rs | 32 +++++++++++++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/program/src/instructions/helpers/token.rs b/program/src/instructions/helpers/token.rs index fab4308..9d649eb 100644 --- a/program/src/instructions/helpers/token.rs +++ b/program/src/instructions/helpers/token.rs @@ -27,7 +27,6 @@ use crate::{ SubscriptionsError, }; -const EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT: u16 = 4; const EXTENSION_TYPE_TRANSFER_HOOK: u16 = 14; const TLV_EXTENSIONS_START: usize = 166; @@ -35,7 +34,7 @@ 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. +/// that have TransferHook extensions. fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { let mut offset = TLV_EXTENSIONS_START; @@ -48,14 +47,8 @@ fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { break; } - match ext_type { - EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT => { - return Err(SubscriptionsError::MintHasConfidentialTransfer.into()); - } - EXTENSION_TYPE_TRANSFER_HOOK => { - return Err(SubscriptionsError::MintHasTransferHook.into()); - } - _ => {} + if ext_type == EXTENSION_TYPE_TRANSFER_HOOK { + return Err(SubscriptionsError::MintHasTransferHook.into()); } offset += 4 + ext_len; diff --git a/tests/integration-tests/src/test_initialize_subscription_authority.rs b/tests/integration-tests/src/test_initialize_subscription_authority.rs index b46d293..4441d39 100644 --- a/tests/integration-tests/src/test_initialize_subscription_authority.rs +++ b/tests/integration-tests/src/test_initialize_subscription_authority.rs @@ -109,7 +109,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], @@ -139,9 +139,9 @@ fn initialize_subscription_authority_with_sponsor() { &[ExtensionType::TransferFeeConfig, ExtensionType::TransferHook], Some(SubscriptionsError::MintHasTransferHook) )] -#[case::mixed_blocked( +#[case::mixed_allowed( &[ExtensionType::MintCloseAuthority, ExtensionType::ConfidentialTransferMint], - Some(SubscriptionsError::MintHasConfidentialTransfer) + None )] fn initialize_subscription_authority_token_2022( #[case] extensions: &[ExtensionType], diff --git a/tests/integration-tests/src/test_transfer_fixed_delegation.rs b/tests/integration-tests/src/test_transfer_fixed_delegation.rs index 3fac111..cd0045f 100644 --- a/tests/integration-tests/src/test_transfer_fixed_delegation.rs +++ b/tests/integration-tests/src/test_transfer_fixed_delegation.rs @@ -110,6 +110,38 @@ 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_multiple_times() { let amount: u64 = 50_000_000; From 8fa0891f6be7103c50d81880b18b3c049425f621 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 14:41:17 -0400 Subject: [PATCH 2/6] feat(program): allow inert transfer hook mints --- program/src/instructions/helpers/token.rs | 31 +++++++----- .../test_initialize_subscription_authority.rs | 49 ++++++++++++++++--- .../src/test_transfer_fixed_delegation.rs | 32 ++++++++++++ .../src/utils/test_helpers.rs | 16 ++++++ 4 files changed, 111 insertions(+), 17 deletions(-) diff --git a/program/src/instructions/helpers/token.rs b/program/src/instructions/helpers/token.rs index 9d649eb..4bb3c0e 100644 --- a/program/src/instructions/helpers/token.rs +++ b/program/src/instructions/helpers/token.rs @@ -28,30 +28,40 @@ use crate::{ }; 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 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::InvalidAccountData.into())?; - // Type 0 = Uninitialized, signals end of TLV entries if ext_type == 0 { break; } + if ext_data_end > data.len() { + return Err(SubscriptionsError::InvalidToken2022MintAccountData.into()); + } + if ext_type == EXTENSION_TYPE_TRANSFER_HOOK { - return Err(SubscriptionsError::MintHasTransferHook.into()); + let extension_data = &data[ext_data_start..ext_data_end]; + if extension_data.len() != TRANSFER_HOOK_EXTENSION_LEN { + return Err(SubscriptionsError::InvalidToken2022MintAccountData.into()); + } + if extension_data.iter().any(|byte| *byte != 0) { + return Err(SubscriptionsError::MintHasTransferHook.into()); + } } - offset += 4 + ext_len; + offset = ext_data_end; } Ok(()) @@ -175,8 +185,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 4441d39..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, @@ -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,11 +136,11 @@ 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_allowed( +#[case::mixed_allowed_confidential( &[ExtensionType::MintCloseAuthority, ExtensionType::ConfidentialTransferMint], None )] @@ -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 cd0045f..e905871 100644 --- a/tests/integration-tests/src/test_transfer_fixed_delegation.rs +++ b/tests/integration-tests/src/test_transfer_fixed_delegation.rs @@ -142,6 +142,38 @@ fn test_fixed_transfer_token_2022_confidential_transfer_public_balance() { 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/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); From 8e00c1511a29ad4d9e7711b4e7b254d174a066f9 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 15:03:50 -0400 Subject: [PATCH 3/6] refactor(program): normalize TLV overflow error variant Return InvalidToken2022MintAccountData on checked_add overflow during mint extension parsing instead of the generic InvalidAccountData, so both bounds-violation paths emit the same, more specific error. --- program/src/instructions/helpers/token.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/program/src/instructions/helpers/token.rs b/program/src/instructions/helpers/token.rs index 4bb3c0e..b0c1809 100644 --- a/program/src/instructions/helpers/token.rs +++ b/program/src/instructions/helpers/token.rs @@ -40,8 +40,9 @@ fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { 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::InvalidAccountData.into())?; + let ext_data_end = ext_data_start + .checked_add(ext_len) + .ok_or::(SubscriptionsError::InvalidToken2022MintAccountData.into())?; if ext_type == 0 { break; From 1fc02fec4e246ab75ebd1fee437269f46815072f Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 15:03:56 -0400 Subject: [PATCH 4/6] test(program): cover token-2022 recurring transfers Mirror the new fixed-delegation token-2022 tests onto the recurring pathway: a confidential-transfer mint and an unconfigured transfer hook both complete a recurring transfer against the public balance. --- .../src/test_transfer_recurring_delegation.rs | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) 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); +} From 4f021777d0c2fdc7167334b9fe488ca97dc4188e Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 15:04:06 -0400 Subject: [PATCH 5/6] docs: clarify token-2022 extension policy CLAUDE.md previously claimed the program rejects every non-trivial Token-2022 extension. Update it to reflect the current rule: only mints with a configured TransferHook are rejected; all other extensions (ConfidentialTransferMint, NonTransferable, PermanentDelegate, TransferFeeConfig, MintCloseAuthority, Pausable) are accepted. Add a matching section to ADR-001 explaining why an inert TransferHook is safe (no authority means the hook can never be installed) and why a ConfidentialTransferMint does not break subscription transfers (the public balance is unaffected by the extension). Annotate the now-dormant MintHasConfidentialTransfer error variant as compat-reserved so downstream clients can keep their existing error handlers. --- CLAUDE.md | 2 +- docs/001-program-architecture.md | 23 +++++++++++++++++++++++ program/src/errors.rs | 3 +++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index acdad39..2cc4c69 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. All other extensions — `ConfidentialTransferMint`, `NonTransferable`, `PermanentDelegate`, `TransferFeeConfig`, `MintCloseAuthority`, `Pausable` — are accepted; downstream UX must surface the corresponding risks. diff --git a/docs/001-program-architecture.md b/docs/001-program-architecture.md index 7e7c47a..8240826 100644 --- a/docs/001-program-architecture.md +++ b/docs/001-program-architecture.md @@ -502,3 +502,26 @@ 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` (in `program/src/instructions/helpers/token.rs`) walks the TLV +extension list on a Token-2022 mint and rejects only mints with a **configured** +`TransferHook` extension — that is, where the hook `authority` or `program_id` field is +set to a non-zero pubkey. An inert `TransferHook` (both fields unset) is accepted: with +the hook authority empty, no one can ever call `update_transfer_hook` to install a +program, so the hook remains permanently dormant and transfers via the SubscriptionAuthority +incur no extra CPI. Mints carrying any other extension — `ConfidentialTransferMint`, +`NonTransferable`, `PermanentDelegate`, `TransferFeeConfig`, `MintCloseAuthority`, +`Pausable` — are allowed: + +- `ConfidentialTransferMint` operates on a separate, encrypted balance bucket. SPL + transfers initiated by the SubscriptionAuthority move the public balance, which the + extension does not affect. A delegator who deposits public balance into the + confidential side starves their own subscribers — the same failure mode as moving the + tokens elsewhere — so the risk is consent-shaped, not protocol-shaped. +- `TransferFeeConfig` is handled by `transfer_utils::transfer_checked_with_fee`, which + routes through the Token-2022 fee accounting path. +- The remaining extensions (`NonTransferable`, `PermanentDelegate`, `MintCloseAuthority`, + `Pausable`) are accepted as an explicit consent-over-paternalism trade-off; downstream + UX is responsible for surfacing the issuer-side capabilities they introduce. diff --git a/program/src/errors.rs b/program/src/errors.rs index 3fc6263..61e10e6 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -143,6 +143,9 @@ pub enum SubscriptionsError { ArithmeticUnderflow, #[error("Invalid account discriminator")] InvalidAccountDiscriminator, + /// Reserved for backwards compatibility. No longer emitted: confidential + /// transfer mints are accepted because subscription transfers use the + /// public balance, which is unaffected by the extension. #[error("Mint has ConfidentialTransfer extension")] MintHasConfidentialTransfer, #[error("Mint has NonTransferable extension")] From 26ab457669c805d4041f20ca19a7e3f1f98e3e62 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 15:07:47 -0400 Subject: [PATCH 6/6] docs: trim token-2022 extension notes Drop the prose explaining why each non-rejected extension is safe; the implementation and tests are the source of truth. --- CLAUDE.md | 2 +- docs/001-program-architecture.md | 24 ++++-------------------- program/src/errors.rs | 4 +--- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2cc4c69..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 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. All other extensions — `ConfidentialTransferMint`, `NonTransferable`, `PermanentDelegate`, `TransferFeeConfig`, `MintCloseAuthority`, `Pausable` — are accepted; downstream UX must surface the corresponding risks. +- **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 8240826..1f76d8e 100644 --- a/docs/001-program-architecture.md +++ b/docs/001-program-architecture.md @@ -505,23 +505,7 @@ Executes a transfer for a recurring delegation. ## Token-2022 Extension Policy -`Mint2022Account::check` (in `program/src/instructions/helpers/token.rs`) walks the TLV -extension list on a Token-2022 mint and rejects only mints with a **configured** -`TransferHook` extension — that is, where the hook `authority` or `program_id` field is -set to a non-zero pubkey. An inert `TransferHook` (both fields unset) is accepted: with -the hook authority empty, no one can ever call `update_transfer_hook` to install a -program, so the hook remains permanently dormant and transfers via the SubscriptionAuthority -incur no extra CPI. Mints carrying any other extension — `ConfidentialTransferMint`, -`NonTransferable`, `PermanentDelegate`, `TransferFeeConfig`, `MintCloseAuthority`, -`Pausable` — are allowed: - -- `ConfidentialTransferMint` operates on a separate, encrypted balance bucket. SPL - transfers initiated by the SubscriptionAuthority move the public balance, which the - extension does not affect. A delegator who deposits public balance into the - confidential side starves their own subscribers — the same failure mode as moving the - tokens elsewhere — so the risk is consent-shaped, not protocol-shaped. -- `TransferFeeConfig` is handled by `transfer_utils::transfer_checked_with_fee`, which - routes through the Token-2022 fee accounting path. -- The remaining extensions (`NonTransferable`, `PermanentDelegate`, `MintCloseAuthority`, - `Pausable`) are accepted as an explicit consent-over-paternalism trade-off; downstream - UX is responsible for surfacing the issuer-side capabilities they introduce. +`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 61e10e6..962b533 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -143,9 +143,7 @@ pub enum SubscriptionsError { ArithmeticUnderflow, #[error("Invalid account discriminator")] InvalidAccountDiscriminator, - /// Reserved for backwards compatibility. No longer emitted: confidential - /// transfer mints are accepted because subscription transfers use the - /// public balance, which is unaffected by the extension. + /// Reserved for backwards compatibility. #[error("Mint has ConfidentialTransfer extension")] MintHasConfidentialTransfer, #[error("Mint has NonTransferable extension")]