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]; diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index eed8ccacc7..cc1330cabe 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -6,7 +6,9 @@ use pinocchio_token_program::processor::{ 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, }; @@ -48,6 +50,12 @@ 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 validate_self_transfer(source, destination, &accounts[ACCOUNT_AUTHORITY])? { + 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..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, 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 @@ -38,10 +40,17 @@ 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 validate_self_transfer(source, destination, &accounts[ACCOUNT_AUTHORITY])? { + 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) 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)]