From a179aefa71e0fb1a4f5c7b04a472853a5dd67a44 Mon Sep 17 00:00:00 2001 From: ananas-block Date: Fri, 6 Feb 2026 08:05:58 +0100 Subject: [PATCH 1/6] fix: handle self-transfer in ctoken transfer and transfer_checked Validate that the authority is a signer and is the owner or delegate before allowing self-transfer early return. Previously the self-transfer path returned Ok(()) without any authority validation. --- .../program/src/ctoken/transfer/checked.rs | 24 ++++++++++++++++- .../program/src/ctoken/transfer/default.rs | 27 +++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index eed8ccacc7..db14772cb6 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -1,6 +1,7 @@ use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_program_profiler::profile; -use pinocchio::account_info::AccountInfo; +use light_token_interface::state::Token; +use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; use pinocchio_token_program::processor::{ shared::transfer::process_transfer, transfer_checked::process_transfer_checked, unpack_amount_and_decimals, @@ -48,6 +49,27 @@ pub fn process_ctoken_transfer_checked( let source = &accounts[ACCOUNT_SOURCE]; let destination = &accounts[ACCOUNT_DESTINATION]; + // Self-transfer: validate authority but skip token movement to avoid + // double mutable borrow panic in pinocchio process_transfer. + if source.key() == destination.key() { + let authority = &accounts[ACCOUNT_AUTHORITY]; + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + let token = Token::from_account_info_checked(source) + .map_err(|_| ProgramError::InvalidAccountData)?; + let is_owner = pubkey_eq(authority.key(), token.base.owner.array_ref()); + let is_delegate = token + .base + .delegate() + .map_or(false, |d| pubkey_eq(authority.key(), d.array_ref())); + if !is_owner && !is_delegate { + msg!("Self-transfer authority must be owner or delegate"); + return Err(ProgramError::InvalidAccountData); + } + return Ok(()); + } + // Hot path: 165-byte accounts have no extensions, skip all extension processing if source.data_len() == 165 && destination.data_len() == 165 { // Slice to exactly 4 accounts: [source, mint, destination, authority] diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index 488f0ed8e6..823b24041f 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -1,6 +1,7 @@ use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_program_profiler::profile; -use pinocchio::account_info::AccountInfo; +use light_token_interface::state::Token; +use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; use pinocchio_token_program::processor::transfer::process_transfer; use super::shared::{process_transfer_extensions_transfer, TransferAccounts}; @@ -38,10 +39,32 @@ pub fn process_ctoken_transfer( return Err(ProgramError::InvalidInstructionData); } - // Hot path: 165-byte accounts have no extensions, skip all extension processing // SAFETY: accounts.len() >= 3 validated at function entry let source = &accounts[ACCOUNT_SOURCE]; let destination = &accounts[ACCOUNT_DESTINATION]; + + // Self-transfer: validate authority but skip token movement to avoid + // double mutable borrow panic in pinocchio process_transfer. + if source.key() == destination.key() { + let authority = &accounts[ACCOUNT_AUTHORITY]; + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + let token = Token::from_account_info_checked(source) + .map_err(|_| ProgramError::InvalidAccountData)?; + let is_owner = pubkey_eq(authority.key(), token.base.owner.array_ref()); + let is_delegate = token + .base + .delegate() + .map_or(false, |d| pubkey_eq(authority.key(), d.array_ref())); + if !is_owner && !is_delegate { + msg!("Self-transfer authority must be owner or delegate"); + return Err(ProgramError::InvalidAccountData); + } + return Ok(()); + } + + // Hot path: 165-byte accounts have no extensions, skip all extension processing if source.data_len() == 165 && destination.data_len() == 165 { // Slice to exactly 3 accounts: [source, destination, authority] return process_transfer(&accounts[..3], &instruction_data[..8], false) From a9c0a0d629dbb35fc9f329c4453144673e7cfc68 Mon Sep 17 00:00:00 2001 From: ananas-block Date: Fri, 6 Feb 2026 08:42:58 +0100 Subject: [PATCH 2/6] fix: simplify map_or to is_some_and per clippy --- .../compressed-token/program/src/ctoken/transfer/checked.rs | 2 +- .../compressed-token/program/src/ctoken/transfer/default.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index db14772cb6..dfc28de28d 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -62,7 +62,7 @@ pub fn process_ctoken_transfer_checked( let is_delegate = token .base .delegate() - .map_or(false, |d| pubkey_eq(authority.key(), d.array_ref())); + .is_some_and(|d| pubkey_eq(authority.key(), d.array_ref())); if !is_owner && !is_delegate { msg!("Self-transfer authority must be owner or delegate"); return Err(ProgramError::InvalidAccountData); diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index 823b24041f..d121807120 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -56,7 +56,7 @@ pub fn process_ctoken_transfer( let is_delegate = token .base .delegate() - .map_or(false, |d| pubkey_eq(authority.key(), d.array_ref())); + .is_some_and(|d| pubkey_eq(authority.key(), d.array_ref())); if !is_owner && !is_delegate { msg!("Self-transfer authority must be owner or delegate"); return Err(ProgramError::InvalidAccountData); From f5eec035afd8954e3417b4d8368fb013140ce767 Mon Sep 17 00:00:00 2001 From: ananas-block Date: Mon, 9 Feb 2026 06:34:02 +0100 Subject: [PATCH 3/6] fix: use pubkey_eq for self-transfer check --- .../compressed-token/program/src/ctoken/transfer/checked.rs | 2 +- .../compressed-token/program/src/ctoken/transfer/default.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index dfc28de28d..7b784add32 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -51,7 +51,7 @@ pub fn process_ctoken_transfer_checked( // Self-transfer: validate authority but skip token movement to avoid // double mutable borrow panic in pinocchio process_transfer. - if source.key() == destination.key() { + if pubkey_eq(source.key(), destination.key()) { let authority = &accounts[ACCOUNT_AUTHORITY]; if !authority.is_signer() { return Err(ProgramError::MissingRequiredSignature); diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index d121807120..a5d07c479e 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -45,7 +45,7 @@ pub fn process_ctoken_transfer( // Self-transfer: validate authority but skip token movement to avoid // double mutable borrow panic in pinocchio process_transfer. - if source.key() == destination.key() { + if pubkey_eq(source.key(), destination.key()) { let authority = &accounts[ACCOUNT_AUTHORITY]; if !authority.is_signer() { return Err(ProgramError::MissingRequiredSignature); From 89d1653a70cdd4d4a5367343954ab73435a1bfba Mon Sep 17 00:00:00 2001 From: ananas-block Date: Mon, 9 Feb 2026 06:39:52 +0100 Subject: [PATCH 4/6] refactor: extract self-transfer validation into shared function Extract duplicate self-transfer check from default.rs and checked.rs into validate_self_transfer() in shared.rs with cold path for authority validation. --- .../program/src/ctoken/transfer/checked.rs | 24 +++-------- .../program/src/ctoken/transfer/default.rs | 22 ++-------- .../program/src/ctoken/transfer/shared.rs | 41 ++++++++++++++++++- 3 files changed, 48 insertions(+), 39 deletions(-) diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index 7b784add32..cc1330cabe 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -1,13 +1,14 @@ use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_program_profiler::profile; -use light_token_interface::state::Token; -use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; +use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::{ shared::transfer::process_transfer, transfer_checked::process_transfer_checked, unpack_amount_and_decimals, }; -use super::shared::{process_transfer_extensions_transfer_checked, TransferAccounts}; +use super::shared::{ + process_transfer_extensions_transfer_checked, validate_self_transfer, TransferAccounts, +}; use crate::shared::{ convert_pinocchio_token_error, convert_token_error, owner_validation::check_token_program_owner, }; @@ -51,22 +52,7 @@ pub fn process_ctoken_transfer_checked( // Self-transfer: validate authority but skip token movement to avoid // double mutable borrow panic in pinocchio process_transfer. - if pubkey_eq(source.key(), destination.key()) { - let authority = &accounts[ACCOUNT_AUTHORITY]; - if !authority.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - let token = Token::from_account_info_checked(source) - .map_err(|_| ProgramError::InvalidAccountData)?; - let is_owner = pubkey_eq(authority.key(), token.base.owner.array_ref()); - let is_delegate = token - .base - .delegate() - .is_some_and(|d| pubkey_eq(authority.key(), d.array_ref())); - if !is_owner && !is_delegate { - msg!("Self-transfer authority must be owner or delegate"); - return Err(ProgramError::InvalidAccountData); - } + if validate_self_transfer(source, destination, &accounts[ACCOUNT_AUTHORITY])? { return Ok(()); } diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index a5d07c479e..3d0d2c9157 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -1,10 +1,9 @@ use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_program_profiler::profile; -use light_token_interface::state::Token; -use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; +use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::transfer::process_transfer; -use super::shared::{process_transfer_extensions_transfer, TransferAccounts}; +use super::shared::{process_transfer_extensions_transfer, validate_self_transfer, TransferAccounts}; use crate::shared::convert_pinocchio_token_error; /// Account indices for CToken transfer instruction @@ -45,22 +44,7 @@ pub fn process_ctoken_transfer( // Self-transfer: validate authority but skip token movement to avoid // double mutable borrow panic in pinocchio process_transfer. - if pubkey_eq(source.key(), destination.key()) { - let authority = &accounts[ACCOUNT_AUTHORITY]; - if !authority.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - let token = Token::from_account_info_checked(source) - .map_err(|_| ProgramError::InvalidAccountData)?; - let is_owner = pubkey_eq(authority.key(), token.base.owner.array_ref()); - let is_delegate = token - .base - .delegate() - .is_some_and(|d| pubkey_eq(authority.key(), d.array_ref())); - if !is_owner && !is_delegate { - msg!("Self-transfer authority must be owner or delegate"); - return Err(ProgramError::InvalidAccountData); - } + if validate_self_transfer(source, destination, &accounts[ACCOUNT_AUTHORITY])? { return Ok(()); } diff --git a/programs/compressed-token/program/src/ctoken/transfer/shared.rs b/programs/compressed-token/program/src/ctoken/transfer/shared.rs index 06aee4b40d..53afb31efd 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/shared.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/shared.rs @@ -1,5 +1,5 @@ use anchor_compressed_token::ErrorCode; -use anchor_lang::solana_program::program_error::ProgramError; +use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_program_profiler::profile; use light_token_interface::{ state::{Token, ZExtensionStructMut}, @@ -15,6 +15,45 @@ use crate::{ }, }; +/// Validates self-transfer: if source == destination, checks authority is signer +/// and is owner or delegate of the token account. +/// Returns Ok(true) if self-transfer was validated (caller should return Ok(())), +/// Returns Ok(false) if not a self-transfer (caller should continue). +#[inline(always)] +pub fn validate_self_transfer( + source: &AccountInfo, + destination: &AccountInfo, + authority: &AccountInfo, +) -> Result { + if !pubkey_eq(source.key(), destination.key()) { + return Ok(false); + } + validate_self_transfer_authority(source, authority)?; + Ok(true) +} + +#[cold] +fn validate_self_transfer_authority( + source: &AccountInfo, + authority: &AccountInfo, +) -> Result<(), ProgramError> { + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + let token = + Token::from_account_info_checked(source).map_err(|_| ProgramError::InvalidAccountData)?; + let is_owner = pubkey_eq(authority.key(), token.base.owner.array_ref()); + let is_delegate = token + .base + .delegate() + .is_some_and(|d| pubkey_eq(authority.key(), d.array_ref())); + if !is_owner && !is_delegate { + msg!("Self-transfer authority must be owner or delegate"); + return Err(ProgramError::InvalidAccountData); + } + Ok(()) +} + /// Extension information detected from a single account deserialization. /// Uses `MintExtensionFlags` for T22 extension flags to avoid duplication. #[derive(Debug, Default)] From 54dc238d9333a6cb388357713d88ad9390e2a29d Mon Sep 17 00:00:00 2001 From: ananas-block Date: Mon, 9 Feb 2026 07:01:13 +0100 Subject: [PATCH 5/6] chore: format --- .../compressed-token/program/src/ctoken/transfer/default.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index 3d0d2c9157..443b44906a 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -3,7 +3,9 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::transfer::process_transfer; -use super::shared::{process_transfer_extensions_transfer, validate_self_transfer, TransferAccounts}; +use super::shared::{ + process_transfer_extensions_transfer, validate_self_transfer, TransferAccounts, +}; use crate::shared::convert_pinocchio_token_error; /// Account indices for CToken transfer instruction From bfceac3bf192a278e09afc2844a13404f9d76e79 Mon Sep 17 00:00:00 2001 From: ananas-block Date: Mon, 9 Feb 2026 10:44:16 +0100 Subject: [PATCH 6/6] fix: deduplicate random metadata keys in test_random_mint_action Random key generation could produce duplicate keys, causing DuplicateMetadataKey error (18040) with certain seeds. --- program-tests/compressed-token-test/tests/mint/random.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/program-tests/compressed-token-test/tests/mint/random.rs b/program-tests/compressed-token-test/tests/mint/random.rs index 0b20f280db..1b8730bb8b 100644 --- a/program-tests/compressed-token-test/tests/mint/random.rs +++ b/program-tests/compressed-token-test/tests/mint/random.rs @@ -39,13 +39,17 @@ async fn test_random_mint_action() { println!("\n\ntest seed {}\n\n", seed); let mut rng = StdRng::seed_from_u64(seed); - // Generate random custom metadata keys (max 20) + // Generate random custom metadata keys (max 20, deduplicated) let num_keys = rng.gen_range(1..=20); let mut available_keys = Vec::new(); let mut initial_metadata = Vec::new(); for i in 0..num_keys { let key_len = rng.gen_range(1..=8); // Random key length 1-8 bytes let key: Vec = (0..key_len).map(|_| rng.gen()).collect(); + // Skip duplicate keys to avoid DuplicateMetadataKey error + if available_keys.contains(&key) { + continue; + } let value_len = rng.gen_range(5..=32); // Random value length let value = vec![(i + 2) as u8; value_len];