From 54de94beb39fe1ab938effdf83e4195d249f4925 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 13:46:37 -0400 Subject: [PATCH 1/3] feat(program): allow additional token extensions --- program/src/instructions/helpers/token.rs | 19 +------------------ .../test_initialize_subscription_authority.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/program/src/instructions/helpers/token.rs b/program/src/instructions/helpers/token.rs index fb92bea..c81b141 100644 --- a/program/src/instructions/helpers/token.rs +++ b/program/src/instructions/helpers/token.rs @@ -28,20 +28,15 @@ use crate::{ }; const EXTENSION_TYPE_TRANSFER_FEE_CONFIG: u16 = 1; -const EXTENSION_TYPE_MINT_CLOSE_AUTHORITY: u16 = 3; const EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT: u16 = 4; -const EXTENSION_TYPE_NON_TRANSFERABLE: u16 = 9; -const EXTENSION_TYPE_PERMANENT_DELEGATE: u16 = 12; const EXTENSION_TYPE_TRANSFER_HOOK: u16 = 14; -const EXTENSION_TYPE_PAUSABLE: u16 = 26; 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, NonTransferable, PermanentDelegate, -/// TransferHook, TransferFee, MintCloseAuthority, or Pausable extensions. +/// that have ConfidentialTransfer, TransferHook, or TransferFee extensions. fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { let mut offset = TLV_EXTENSIONS_START; @@ -58,24 +53,12 @@ fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { EXTENSION_TYPE_TRANSFER_FEE_CONFIG => { return Err(SubscriptionsError::MintHasTransferFee.into()); } - EXTENSION_TYPE_MINT_CLOSE_AUTHORITY => { - return Err(SubscriptionsError::MintHasMintCloseAuthority.into()); - } EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT => { return Err(SubscriptionsError::MintHasConfidentialTransfer.into()); } - EXTENSION_TYPE_NON_TRANSFERABLE => { - return Err(SubscriptionsError::MintHasNonTransferable.into()); - } - EXTENSION_TYPE_PERMANENT_DELEGATE => { - return Err(SubscriptionsError::MintHasPermanentDelegate.into()); - } EXTENSION_TYPE_TRANSFER_HOOK => { return Err(SubscriptionsError::MintHasTransferHook.into()); } - EXTENSION_TYPE_PAUSABLE => { - return Err(SubscriptionsError::MintHasPausable.into()); - } _ => {} } diff --git a/tests/integration-tests/src/test_initialize_subscription_authority.rs b/tests/integration-tests/src/test_initialize_subscription_authority.rs index d0acab6..f4c5b47 100644 --- a/tests/integration-tests/src/test_initialize_subscription_authority.rs +++ b/tests/integration-tests/src/test_initialize_subscription_authority.rs @@ -113,11 +113,11 @@ fn initialize_subscription_authority_with_sponsor() { )] #[case::non_transferable( &[ExtensionType::NonTransferable], - Some(SubscriptionsError::MintHasNonTransferable) + None )] #[case::permanent_delegate( &[ExtensionType::PermanentDelegate], - Some(SubscriptionsError::MintHasPermanentDelegate) + None )] #[case::transfer_fee( &[ExtensionType::TransferFeeConfig], @@ -129,19 +129,19 @@ fn initialize_subscription_authority_with_sponsor() { )] #[case::pausable( &[ExtensionType::Pausable], - Some(SubscriptionsError::MintHasPausable) + None )] #[case::close_authority( &[ExtensionType::MintCloseAuthority], - Some(SubscriptionsError::MintHasMintCloseAuthority) + None )] #[case::multiple_blocked( &[ExtensionType::TransferFeeConfig, ExtensionType::TransferHook], Some(SubscriptionsError::MintHasTransferFee) )] #[case::mixed_blocked( - &[ExtensionType::MintCloseAuthority, ExtensionType::PermanentDelegate], - Some(SubscriptionsError::MintHasMintCloseAuthority) + &[ExtensionType::MintCloseAuthority, ExtensionType::ConfidentialTransferMint], + Some(SubscriptionsError::MintHasConfidentialTransfer) )] fn initialize_subscription_authority_token_2022( #[case] extensions: &[ExtensionType], From 8284da8e85cadc73e27b6afc054f5b1b7c736e48 Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 13:56:19 -0400 Subject: [PATCH 2/3] feat(program): support token transfer fees --- clients/typescript/src/plugin.ts | 2 + idl/subscriptions.json | 27 +++++++ program/src/instructions/helpers/token.rs | 6 +- .../instructions/helpers/transfer_utils.rs | 34 ++++++++- program/src/instructions/mod.rs | 3 + .../instructions/transfer_fixed_delegation.rs | 1 + .../transfer_recurring_delegation.rs | 1 + .../src/instructions/transfer_subscription.rs | 8 +- .../test_initialize_subscription_authority.rs | 4 +- .../src/test_transfer_fixed_delegation.rs | 42 ++++++++++- .../src/test_transfer_recurring_delegation.rs | 2 + .../src/test_transfer_subscription.rs | 2 + .../src/utils/test_helpers.rs | 74 +++++++++++++++---- 13 files changed, 179 insertions(+), 27 deletions(-) diff --git a/clients/typescript/src/plugin.ts b/clients/typescript/src/plugin.ts index db5a2d5..24b3e42 100644 --- a/clients/typescript/src/plugin.ts +++ b/clients/typescript/src/plugin.ts @@ -435,6 +435,7 @@ async function getTransferDelegationOverlayInstructionAsync( delegatorAta: input.delegatorAta, receiverAta: input.receiverAta, subscriptionAuthority, + tokenMint: input.tokenMint, tokenProgram: input.tokenProgram, transferData: { amount: input.amount, @@ -475,6 +476,7 @@ export async function getTransferSubscriptionOverlayInstructionAsync( receiverAta: input.receiverAta, subscriptionAuthority, subscriptionPda: input.subscriptionPda, + tokenMint: input.tokenMint, tokenProgram: input.tokenProgram, transferData: { amount: input.amount, diff --git a/idl/subscriptions.json b/idl/subscriptions.json index 58cebcc..c287fdf 100644 --- a/idl/subscriptions.json +++ b/idl/subscriptions.json @@ -1638,6 +1638,15 @@ "kind": "instructionAccountNode", "name": "receiverAta" }, + { + "docs": [ + "The token mint" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "tokenMint" + }, { "docs": [ "Token program" @@ -1755,6 +1764,15 @@ "kind": "instructionAccountNode", "name": "receiverAta" }, + { + "docs": [ + "The token mint" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "tokenMint" + }, { "docs": [ "Token program" @@ -2129,6 +2147,15 @@ "kind": "instructionAccountNode", "name": "caller" }, + { + "docs": [ + "The token mint" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "tokenMint" + }, { "docs": [ "Token program" diff --git a/program/src/instructions/helpers/token.rs b/program/src/instructions/helpers/token.rs index c81b141..fab4308 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_TRANSFER_FEE_CONFIG: u16 = 1; const EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT: u16 = 4; const EXTENSION_TYPE_TRANSFER_HOOK: u16 = 14; @@ -36,7 +35,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, TransferHook, or TransferFee extensions. +/// that have ConfidentialTransfer or TransferHook extensions. fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { let mut offset = TLV_EXTENSIONS_START; @@ -50,9 +49,6 @@ fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> { } match ext_type { - EXTENSION_TYPE_TRANSFER_FEE_CONFIG => { - return Err(SubscriptionsError::MintHasTransferFee.into()); - } EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT => { return Err(SubscriptionsError::MintHasConfidentialTransfer.into()); } diff --git a/program/src/instructions/helpers/transfer_utils.rs b/program/src/instructions/helpers/transfer_utils.rs index b1f3e68..02501ec 100644 --- a/program/src/instructions/helpers/transfer_utils.rs +++ b/program/src/instructions/helpers/transfer_utils.rs @@ -3,16 +3,19 @@ use pinocchio::{ error::ProgramError, AccountView, Address, ProgramResult, }; -use pinocchio_token_2022::instructions::Transfer; +use pinocchio_token_2022::instructions::TransferChecked; use crate::{ constants::{ TOKEN_ACCOUNT_MINT_END, TOKEN_ACCOUNT_MINT_OFFSET, TOKEN_ACCOUNT_OWNER_END, TOKEN_ACCOUNT_OWNER_OFFSET, }, - AccountCheck, ProgramAccount, SignerAccount, SubscriptionAuthority, SubscriptionAuthorityAccount, + AccountCheck, MintInterface, ProgramAccount, SignerAccount, SubscriptionAuthority, SubscriptionAuthorityAccount, SubscriptionsError, TokenAccountInterface, TokenProgramInterface, WritableAccount, }; +const MINT_DECIMALS_OFFSET: usize = 44; +const MINT_DECIMALS_END: usize = MINT_DECIMALS_OFFSET + 1; + /// Verifies that the token account's owner field matches `expected`. pub fn check_token_account_owner(data: &[u8], expected: &Address) -> Result<(), SubscriptionsError> { if data.len() < TOKEN_ACCOUNT_OWNER_END { @@ -45,12 +48,20 @@ pub fn get_token_account_owner(data: &[u8]) -> Result Result { + if data.len() < MINT_DECIMALS_END { + return Err(SubscriptionsError::InvalidAccountData); + } + Ok(data[MINT_DECIMALS_OFFSET]) +} + /// Validated accounts shared by `TransferFixed` and `TransferRecurring` (identical layouts). pub struct DelegationTransferAccounts<'a> { pub delegation_pda: &'a mut AccountView, pub subscription_authority: &'a AccountView, pub delegator_ata: &'a AccountView, pub receiver_ata: &'a AccountView, + pub token_mint: &'a AccountView, pub token_program: &'a AccountView, pub delegatee: &'a AccountView, pub event_authority: &'a AccountView, @@ -61,7 +72,7 @@ impl<'a> TryFrom<&'a mut [AccountView]> for DelegationTransferAccounts<'a> { type Error = ProgramError; fn try_from(accounts: &'a mut [AccountView]) -> Result { - let [delegation_pda, subscription_authority, delegator_ata, receiver_ata, token_program, delegatee, event_authority, self_program] = + let [delegation_pda, subscription_authority, delegator_ata, receiver_ata, token_mint, token_program, delegatee, event_authority, self_program] = accounts else { return Err(SubscriptionsError::NotEnoughAccountKeys.into()); @@ -73,6 +84,7 @@ impl<'a> TryFrom<&'a mut [AccountView]> for DelegationTransferAccounts<'a> { WritableAccount::check(receiver_ata)?; SubscriptionAuthorityAccount::check(subscription_authority)?; TokenProgramInterface::check(token_program)?; + MintInterface::check_with_program(token_mint, token_program)?; TokenAccountInterface::check_accounts_with_program(token_program, &[delegator_ata, receiver_ata])?; SignerAccount::check(delegatee)?; @@ -81,6 +93,7 @@ impl<'a> TryFrom<&'a mut [AccountView]> for DelegationTransferAccounts<'a> { subscription_authority, delegator_ata, receiver_ata, + token_mint, token_program, delegatee, event_authority, @@ -95,6 +108,8 @@ pub struct TransferAccounts<'a> { pub delegator_ata: &'a AccountView, /// The receiver's Associated Token Account (destination). pub to_ata: &'a AccountView, + /// The token mint being transferred. + pub token_mint: &'a AccountView, /// The [`SubscriptionAuthority`] PDA that is the SPL delegate on `delegator_ata`. pub subscription_authority_pda: &'a AccountView, /// The token program (SPL Token or Token-2022). @@ -113,6 +128,10 @@ pub fn transfer_with_delegate( init_id: i64, accounts: &TransferAccounts, ) -> ProgramResult { + if accounts.token_mint.address() != mint { + return Err(SubscriptionsError::MintMismatch.into()); + } + let bump = { // Read the bump from the SubscriptionAuthority account data (cheaper than find_program_address) let subscription_authority_data = accounts.subscription_authority_pda.try_borrow()?; @@ -149,6 +168,11 @@ pub fn transfer_with_delegate( check_token_account_mint(&to_data, mint)?; } + let decimals = { + let mint_data = accounts.token_mint.try_borrow()?; + get_mint_decimals(&mint_data)? + }; + let bump_bytes = [bump]; let seeds = [ Seed::from(SubscriptionAuthority::SEED), @@ -158,11 +182,13 @@ pub fn transfer_with_delegate( ]; let signer = [Signer::from(&seeds)]; - Transfer { + TransferChecked { from: accounts.delegator_ata, + mint: accounts.token_mint, to: accounts.to_ata, authority: accounts.subscription_authority_pda, amount, + decimals, token_program: accounts.token_program.address(), } .invoke_signed(&signer)?; diff --git a/program/src/instructions/mod.rs b/program/src/instructions/mod.rs index 26d6533..dab7e3a 100644 --- a/program/src/instructions/mod.rs +++ b/program/src/instructions/mod.rs @@ -100,6 +100,7 @@ pub enum SubscriptionsInstruction { #[codama(account(name = "subscription_authority", docs = "The subscription-authority PDA"))] #[codama(account(name = "delegator_ata", writable, docs = "The delegator's ATA to transfer from"))] #[codama(account(name = "receiver_ata", writable, docs = "The receiver's ATA to transfer to"))] + #[codama(account(name = "token_mint", docs = "The token mint"))] #[codama(account(name = "token_program", docs = "Token program"))] #[codama(account(name = "delegatee", signer, docs = "The delegatee signing the transfer"))] #[codama(account( @@ -118,6 +119,7 @@ pub enum SubscriptionsInstruction { #[codama(account(name = "subscription_authority", docs = "The subscription-authority PDA"))] #[codama(account(name = "delegator_ata", writable, docs = "The delegator's ATA to transfer from"))] #[codama(account(name = "receiver_ata", writable, docs = "The receiver's ATA to transfer to"))] + #[codama(account(name = "token_mint", docs = "The token mint"))] #[codama(account(name = "token_program", docs = "Token program"))] #[codama(account(name = "delegatee", signer, docs = "The delegatee signing the transfer"))] #[codama(account( @@ -170,6 +172,7 @@ pub enum SubscriptionsInstruction { #[codama(account(name = "delegator_ata", writable, docs = "The delegator's ATA to transfer from"))] #[codama(account(name = "receiver_ata", writable, docs = "The receiver's ATA to transfer to"))] #[codama(account(name = "caller", signer, docs = "The authorized puller (plan owner or whitelisted)"))] + #[codama(account(name = "token_mint", docs = "The token mint"))] #[codama(account(name = "token_program", docs = "Token program"))] #[codama(account( name = "event_authority", diff --git a/program/src/instructions/transfer_fixed_delegation.rs b/program/src/instructions/transfer_fixed_delegation.rs index e696a3a..b4ad67f 100644 --- a/program/src/instructions/transfer_fixed_delegation.rs +++ b/program/src/instructions/transfer_fixed_delegation.rs @@ -67,6 +67,7 @@ pub fn process(accounts: &mut [AccountView], transfer: &TransferData) -> Program &TransferAccounts { delegator_ata: accounts_struct.delegator_ata, to_ata: accounts_struct.receiver_ata, + token_mint: accounts_struct.token_mint, subscription_authority_pda: accounts_struct.subscription_authority, token_program: accounts_struct.token_program, }, diff --git a/program/src/instructions/transfer_recurring_delegation.rs b/program/src/instructions/transfer_recurring_delegation.rs index 1d5d350..3cdc3d9 100644 --- a/program/src/instructions/transfer_recurring_delegation.rs +++ b/program/src/instructions/transfer_recurring_delegation.rs @@ -79,6 +79,7 @@ pub fn process(accounts: &mut [AccountView], transfer_data: &TransferData) -> Pr &TransferAccounts { delegator_ata: accounts_struct.delegator_ata, to_ata: accounts_struct.receiver_ata, + token_mint: accounts_struct.token_mint, subscription_authority_pda: accounts_struct.subscription_authority, token_program: accounts_struct.token_program, }, diff --git a/program/src/instructions/transfer_subscription.rs b/program/src/instructions/transfer_subscription.rs index e1e39a6..ffdc5a3 100644 --- a/program/src/instructions/transfer_subscription.rs +++ b/program/src/instructions/transfer_subscription.rs @@ -10,7 +10,7 @@ use crate::{ events::SubscriptionTransferEvent, helpers::{transfer_with_delegate, validate_recurring_transfer, TransferAccounts, TransferData}, state::{plan::Plan, subscription_delegation::SubscriptionDelegation}, - AccountCheck, ProgramAccount, SignerAccount, SubscriptionAuthorityAccount, SubscriptionsError, + AccountCheck, MintInterface, ProgramAccount, SignerAccount, SubscriptionAuthorityAccount, SubscriptionsError, TokenAccountInterface, TokenProgramInterface, WritableAccount, }; @@ -114,6 +114,7 @@ pub fn process(accounts: &mut [AccountView], transfer_data: &TransferData) -> Pr &TransferAccounts { delegator_ata: accounts_struct.delegator_ata, to_ata: accounts_struct.receiver_ata, + token_mint: accounts_struct.token_mint, subscription_authority_pda: accounts_struct.subscription_authority, token_program: accounts_struct.token_program, }, @@ -153,6 +154,7 @@ pub struct TransferSubscriptionAccounts<'a> { pub delegator_ata: &'a AccountView, pub receiver_ata: &'a AccountView, pub caller: &'a AccountView, + pub token_mint: &'a AccountView, pub token_program: &'a AccountView, pub event_authority: &'a AccountView, pub self_program: &'a AccountView, @@ -162,7 +164,7 @@ impl<'a> TryFrom<&'a mut [AccountView]> for TransferSubscriptionAccounts<'a> { type Error = ProgramError; fn try_from(accounts: &'a mut [AccountView]) -> Result { - let [subscription_pda, plan_pda, subscription_authority, delegator_ata, receiver_ata, caller, token_program, event_authority, self_program] = + let [subscription_pda, plan_pda, subscription_authority, delegator_ata, receiver_ata, caller, token_mint, token_program, event_authority, self_program] = accounts else { return Err(SubscriptionsError::NotEnoughAccountKeys.into()); @@ -179,6 +181,7 @@ impl<'a> TryFrom<&'a mut [AccountView]> for TransferSubscriptionAccounts<'a> { WritableAccount::check(receiver_ata)?; SignerAccount::check(caller)?; TokenProgramInterface::check(token_program)?; + MintInterface::check_with_program(token_mint, token_program)?; TokenAccountInterface::check_accounts_with_program(token_program, &[delegator_ata, receiver_ata])?; Ok(Self { @@ -188,6 +191,7 @@ impl<'a> TryFrom<&'a mut [AccountView]> for TransferSubscriptionAccounts<'a> { delegator_ata, receiver_ata, caller, + token_mint, token_program, event_authority, self_program, diff --git a/tests/integration-tests/src/test_initialize_subscription_authority.rs b/tests/integration-tests/src/test_initialize_subscription_authority.rs index f4c5b47..b46d293 100644 --- a/tests/integration-tests/src/test_initialize_subscription_authority.rs +++ b/tests/integration-tests/src/test_initialize_subscription_authority.rs @@ -121,7 +121,7 @@ fn initialize_subscription_authority_with_sponsor() { )] #[case::transfer_fee( &[ExtensionType::TransferFeeConfig], - Some(SubscriptionsError::MintHasTransferFee) + None )] #[case::transfer_hook( &[ExtensionType::TransferHook], @@ -137,7 +137,7 @@ fn initialize_subscription_authority_with_sponsor() { )] #[case::multiple_blocked( &[ExtensionType::TransferFeeConfig, ExtensionType::TransferHook], - Some(SubscriptionsError::MintHasTransferFee) + Some(SubscriptionsError::MintHasTransferHook) )] #[case::mixed_blocked( &[ExtensionType::MintCloseAuthority, ExtensionType::ConfidentialTransferMint], diff --git a/tests/integration-tests/src/test_transfer_fixed_delegation.rs b/tests/integration-tests/src/test_transfer_fixed_delegation.rs index 714ad00..3fac111 100644 --- a/tests/integration-tests/src/test_transfer_fixed_delegation.rs +++ b/tests/integration-tests/src/test_transfer_fixed_delegation.rs @@ -4,7 +4,7 @@ use crate::{ state::{header::VERSION_OFFSET, FixedDelegation}, 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::{ @@ -21,6 +21,7 @@ use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signer::Signer; 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; fn setup_fixed_delegation( @@ -72,6 +73,43 @@ fn test_fixed_transfer_success() { assert_eq!(del_expiry_s, expiry_ts); } +#[test] +fn test_fixed_transfer_token_2022_transfer_fee() { + 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::TransferFeeConfig], + ); + 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), 9_900_000); + + let delegation_account = litesvm.get_account(&delegation_pda).unwrap(); + let delegation = FixedDelegation::load(&delegation_account.data).unwrap(); + let remaining_amount = delegation.amount; + assert_eq!(remaining_amount, 40_000_000); +} + #[test] fn test_fixed_transfer_multiple_times() { let amount: u64 = 50_000_000; @@ -321,6 +359,7 @@ fn writable_accounts_must_be_writable() { AccountMeta::new_readonly(subscription_authority_pda, false), AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), + AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(bob.pubkey(), true), AccountMeta::new_readonly(event_authority, false), @@ -369,6 +408,7 @@ fn signer_accounts_must_be_signers() { AccountMeta::new_readonly(subscription_authority_pda, false), AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), + AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(bob.pubkey(), true), AccountMeta::new_readonly(event_authority, false), diff --git a/tests/integration-tests/src/test_transfer_recurring_delegation.rs b/tests/integration-tests/src/test_transfer_recurring_delegation.rs index 127c473..f3d4cdc 100644 --- a/tests/integration-tests/src/test_transfer_recurring_delegation.rs +++ b/tests/integration-tests/src/test_transfer_recurring_delegation.rs @@ -410,6 +410,7 @@ fn writable_accounts_must_be_writable() { AccountMeta::new_readonly(subscription_authority_pda, false), AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), + AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(bob.pubkey(), true), AccountMeta::new_readonly(event_authority, false), @@ -461,6 +462,7 @@ fn signer_accounts_must_be_signers() { AccountMeta::new_readonly(subscription_authority_pda, false), AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), + AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(bob.pubkey(), true), AccountMeta::new_readonly(event_authority, false), diff --git a/tests/integration-tests/src/test_transfer_subscription.rs b/tests/integration-tests/src/test_transfer_subscription.rs index eff7045..9ae1651 100644 --- a/tests/integration-tests/src/test_transfer_subscription.rs +++ b/tests/integration-tests/src/test_transfer_subscription.rs @@ -523,6 +523,7 @@ fn writable_accounts_must_be_writable() { AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), AccountMeta::new_readonly(merchant.pubkey(), true), + AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(event_authority, false), AccountMeta::new_readonly(PROGRAM_ID, false), @@ -575,6 +576,7 @@ fn signer_accounts_must_be_signers() { AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), AccountMeta::new_readonly(merchant.pubkey(), true), + AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(event_authority, false), AccountMeta::new_readonly(PROGRAM_ID, false), diff --git a/tests/integration-tests/src/utils/test_helpers.rs b/tests/integration-tests/src/utils/test_helpers.rs index 2d7b80e..92b4319 100644 --- a/tests/integration-tests/src/utils/test_helpers.rs +++ b/tests/integration-tests/src/utils/test_helpers.rs @@ -15,9 +15,15 @@ use solana_transaction::Transaction; use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; use spl_token_2022_interface::{ extension::{ - confidential_transfer::ConfidentialTransferMint, mint_close_authority::MintCloseAuthority, - non_transferable::NonTransferable, pausable::PausableConfig, permanent_delegate::PermanentDelegate, - transfer_fee::TransferFeeConfig, transfer_hook::TransferHook, BaseStateWithExtensionsMut, ExtensionType, + confidential_transfer::ConfidentialTransferMint, + immutable_owner::ImmutableOwner, + mint_close_authority::MintCloseAuthority, + non_transferable::{NonTransferable, NonTransferableAccount}, + pausable::{PausableAccount, PausableConfig}, + permanent_delegate::PermanentDelegate, + transfer_fee::{TransferFeeAmount, TransferFeeConfig}, + transfer_hook::{TransferHook, TransferHookAccount}, + BaseStateWithExtensions, BaseStateWithExtensionsMut, ExtensionType, StateWithExtensions, StateWithExtensionsMut, }, state::{Account as TokenAccount, AccountState, Mint as Mint2022}, @@ -90,15 +96,9 @@ pub fn setup() -> (LiteSVM, Keypair) { (litesvm, default_payer) } -fn pack_data(state: T) -> Vec { - let mut data = vec![0; T::LEN]; - T::pack(state, &mut data).unwrap(); - data -} - pub fn fetch_account(litesvm: &LiteSVM, pubkey: &Pubkey) -> T { let account = litesvm.get_account(pubkey).unwrap(); - T::unpack(account.data.as_ref()).unwrap() + T::unpack(&account.data[..T::LEN]).unwrap() } #[allow(clippy::result_large_err)] @@ -174,7 +174,11 @@ pub fn init_mint( state.init_extension::(true).unwrap(); } ExtensionType::TransferFeeConfig => { - state.init_extension::(true).unwrap(); + let extension = state.init_extension::(true).unwrap(); + extension.older_transfer_fee.epoch = 0.into(); + extension.older_transfer_fee.maximum_fee = 1_000_000.into(); + extension.older_transfer_fee.transfer_fee_basis_points = 100.into(); + extension.newer_transfer_fee = extension.older_transfer_fee; } ExtensionType::TransferHook => { state.init_extension::(true).unwrap(); @@ -220,6 +224,19 @@ fn init_token_account_at( amount: u64, ) -> Pubkey { let token_program = litesvm.get_account(&mint).unwrap().owner; + let account_extensions = if token_program == crate::tests::constants::TOKEN_2022_PROGRAM_ID { + let mint_account = litesvm.get_account(&mint).unwrap(); + let mint_state = StateWithExtensions::::unpack(&mint_account.data).unwrap(); + let mint_extensions = mint_state.get_extension_types().unwrap(); + ExtensionType::get_required_init_account_extensions(&mint_extensions) + } else { + vec![] + }; + let space = if account_extensions.is_empty() { + TokenAccount::LEN + } else { + ExtensionType::try_calculate_account_len::(&account_extensions).unwrap() + }; let ata_state = TokenAccount { mint, owner, @@ -231,8 +248,37 @@ fn init_token_account_at( close_authority: None.into(), }; - let ata_data = pack_data(ata_state); - let lamports = litesvm.minimum_balance_for_rent_exemption(TokenAccount::LEN); + let mut ata_data = vec![0u8; space]; + if account_extensions.is_empty() { + TokenAccount::pack(ata_state, &mut ata_data).unwrap(); + } else { + let mut state = StateWithExtensionsMut::::unpack_uninitialized(&mut ata_data).unwrap(); + state.base = ata_state; + state.pack_base(); + state.init_account_type().unwrap(); + + for ext in account_extensions { + match ext { + ExtensionType::TransferFeeAmount => { + state.init_extension::(true).unwrap(); + } + ExtensionType::NonTransferableAccount => { + state.init_extension::(true).unwrap(); + } + ExtensionType::ImmutableOwner => { + state.init_extension::(true).unwrap(); + } + ExtensionType::TransferHookAccount => { + state.init_extension::(true).unwrap(); + } + ExtensionType::PausableAccount => { + state.init_extension::(true).unwrap(); + } + _ => panic!("Unsupported account extension type in test helper: {:?}", ext), + } + } + } + let lamports = litesvm.minimum_balance_for_rent_exemption(space); litesvm .set_account( @@ -479,6 +525,7 @@ impl<'a> TransferDelegation<'a> { AccountMeta::new(subscription_authority_pda, false), AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), + AccountMeta::new_readonly(self.mint, false), AccountMeta::new_readonly(token_program, false), AccountMeta::new_readonly(self.signer.pubkey(), true), AccountMeta::new_readonly(event_authority, false), @@ -956,6 +1003,7 @@ impl<'a> TransferSubscription<'a> { AccountMeta::new(delegator_ata, false), AccountMeta::new(receiver_ata, false), AccountMeta::new_readonly(self.caller.pubkey(), true), + AccountMeta::new_readonly(self.mint, false), AccountMeta::new_readonly(token_program, false), AccountMeta::new_readonly(event_authority, false), AccountMeta::new_readonly(PROGRAM_ID, false), From 2ab96151267f0077a530553cb7e5243c61d47b3d Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 26 May 2026 14:15:36 -0400 Subject: [PATCH 3/3] fix(program): unpack mint decimals with spl token --- Cargo.lock | 2 ++ Cargo.toml | 2 ++ program/Cargo.toml | 2 ++ program/src/instructions/helpers/transfer_utils.rs | 10 +++------- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 623cf6b..9f39d25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5361,7 +5361,9 @@ dependencies = [ "pinocchio-token-2022", "serde_json", "solana-address 2.6.0", + "solana-program-pack", "solana-security-txt", + "spl-token-interface", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 2c3581e..6210a99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,9 @@ pinocchio-token = "0.6.0" pinocchio-token-2022 = "0.3.1" serde_json = "1" solana-address = { version = "2", features = ["curve25519"] } +solana-program-pack = "3" solana-security-txt = "1.1.3" +spl-token-interface = "2.0.0" thiserror = { version = "2", default-features = false } [profile.release] diff --git a/program/Cargo.toml b/program/Cargo.toml index d0aa66a..2db5ca5 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -28,5 +28,7 @@ pinocchio-system = { workspace = true } pinocchio-token = { workspace = true } pinocchio-token-2022 = { workspace = true } solana-address = { workspace = true } +solana-program-pack = { workspace = true } solana-security-txt = { workspace = true } +spl-token-interface = { workspace = true } thiserror = { workspace = true } diff --git a/program/src/instructions/helpers/transfer_utils.rs b/program/src/instructions/helpers/transfer_utils.rs index 02501ec..654d123 100644 --- a/program/src/instructions/helpers/transfer_utils.rs +++ b/program/src/instructions/helpers/transfer_utils.rs @@ -4,6 +4,8 @@ use pinocchio::{ AccountView, Address, ProgramResult, }; use pinocchio_token_2022::instructions::TransferChecked; +use solana_program_pack::Pack; +use spl_token_interface::state::Mint as TokenMint; use crate::{ constants::{ @@ -13,9 +15,6 @@ use crate::{ SubscriptionsError, TokenAccountInterface, TokenProgramInterface, WritableAccount, }; -const MINT_DECIMALS_OFFSET: usize = 44; -const MINT_DECIMALS_END: usize = MINT_DECIMALS_OFFSET + 1; - /// Verifies that the token account's owner field matches `expected`. pub fn check_token_account_owner(data: &[u8], expected: &Address) -> Result<(), SubscriptionsError> { if data.len() < TOKEN_ACCOUNT_OWNER_END { @@ -49,10 +48,7 @@ pub fn get_token_account_owner(data: &[u8]) -> Result Result { - if data.len() < MINT_DECIMALS_END { - return Err(SubscriptionsError::InvalidAccountData); - } - Ok(data[MINT_DECIMALS_OFFSET]) + TokenMint::unpack_from_slice(data).map(|mint| mint.decimals).map_err(|_| SubscriptionsError::InvalidAccountData) } /// Validated accounts shared by `TransferFixed` and `TransferRecurring` (identical layouts).