diff --git a/program-tests/compressed-token-test/tests/light_token/create.rs b/program-tests/compressed-token-test/tests/light_token/create.rs index a9f9aa5296..f5509ea2f7 100644 --- a/program-tests/compressed-token-test/tests/light_token/create.rs +++ b/program-tests/compressed-token-test/tests/light_token/create.rs @@ -507,7 +507,70 @@ async fn test_create_compressible_token_account_failing() { light_program_test::utils::assert::assert_rpc_error(result, 0, 8).unwrap(); } - // Test 10: Non-compressible account for mint with restricted extensions + // Test 10: Non-compressible account with wrong data length (not 165 bytes) + // Non-compressible accounts must be exactly BASE_TOKEN_ACCOUNT_SIZE (165 bytes). + // Error: 3 (InvalidAccountData) + { + use forester_utils::instructions::create_account::create_account_instruction; + use solana_sdk::instruction::{AccountMeta, Instruction}; + + println!("Test 10: Non-compressible account with wrong data length"); + + // Pre-allocate 200-byte token account owned by ctoken program (wrong size) + let token_account_keypair = Keypair::new(); + let token_account_pubkey = token_account_keypair.pubkey(); + let account_size = 200usize; + + let create_account_ix = create_account_instruction( + &payer_pubkey, + account_size, + context + .rpc + .get_minimum_balance_for_rent_exemption(account_size) + .await + .unwrap(), + &light_compressed_token::ID, + Some(&token_account_keypair), + ); + + context + .rpc + .create_and_send_transaction( + &[create_account_ix], + &payer_pubkey, + &[&context.payer, &token_account_keypair], + ) + .await + .unwrap(); + + // Build manual instruction for non-compressible path + let owner_pubkey = context.owner_keypair.pubkey(); + let mut instruction_data = vec![18u8]; // discriminator + instruction_data.extend_from_slice(&owner_pubkey.to_bytes()); + + let create_non_compressible_ix = Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(token_account_pubkey, false), + AccountMeta::new_readonly(context.mint_pubkey, false), + ], + data: instruction_data, + }; + + let result = context + .rpc + .create_and_send_transaction( + &[create_non_compressible_ix], + &payer_pubkey, + &[&context.payer], + ) + .await; + + // Should fail with InvalidAccountData (3) - wrong data length + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); + } + + // Test 10b: Non-compressible account for mint with restricted extensions // Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) // require the compression_only marker which is part of the Compressible extension. // Error: 6115 (MissingCompressibleConfig) @@ -529,10 +592,10 @@ async fn test_create_compressible_token_account_failing() { .await; let mint_with_restricted_ext = mint_keypair.pubkey(); - // Pre-allocate 200-byte token account owned by ctoken program + // Pre-allocate 165-byte token account owned by ctoken program let token_account_keypair = Keypair::new(); let token_account_pubkey = token_account_keypair.pubkey(); - let account_size = 200usize; + let account_size = 165usize; let create_account_ix = create_account_instruction( &payer_pubkey, diff --git a/program-tests/compressed-token-test/tests/light_token/create_ata.rs b/program-tests/compressed-token-test/tests/light_token/create_ata.rs index 6051155b14..da73589b2a 100644 --- a/program-tests/compressed-token-test/tests/light_token/create_ata.rs +++ b/program-tests/compressed-token-test/tests/light_token/create_ata.rs @@ -262,6 +262,95 @@ async fn test_create_ata_idempotent() { } } +/// Tests that idempotent ATA creation rejects when the existing account's +/// owner field has been changed (e.g. via authority transfer). +/// The PDA derivation passes (same address) but stored owner differs. +#[tokio::test] +async fn test_create_ata_idempotent_owner_mismatch() { + let mut context = setup_account_test().await.unwrap(); + let payer_pubkey = context.payer.pubkey(); + + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(100), + account_version: light_token_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + + // Create ATA (first creation) + let ata_pubkey = create_and_assert_ata( + &mut context, + Some(compressible_data.clone()), + true, + "idempotent_owner_mismatch_first", + ) + .await; + + // Modify the stored owner to a different pubkey + let mut account = context.rpc.get_account(ata_pubkey).await.unwrap().unwrap(); + let different_owner = Pubkey::new_unique(); + // Owner is at bytes 32-63 in SPL token account layout + account.data[32..64].copy_from_slice(&different_owner.to_bytes()); + context.rpc.set_account(ata_pubkey, account); + + // Second idempotent creation should fail because stored owner != instruction owner + create_and_assert_ata_fails( + &mut context, + Some(compressible_data), + true, + "idempotent_owner_mismatch_second", + 3, // InvalidAccountData + ) + .await; +} + +/// Tests that idempotent ATA creation rejects when the existing account's +/// mint field has been tampered with. +#[tokio::test] +async fn test_create_ata_idempotent_mint_mismatch() { + let mut context = setup_account_test().await.unwrap(); + let payer_pubkey = context.payer.pubkey(); + + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(100), + account_version: light_token_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + + // Create ATA (first creation) + let ata_pubkey = create_and_assert_ata( + &mut context, + Some(compressible_data.clone()), + true, + "idempotent_mint_mismatch_first", + ) + .await; + + // Modify the stored mint to a different pubkey + let mut account = context.rpc.get_account(ata_pubkey).await.unwrap().unwrap(); + let different_mint = Pubkey::new_unique(); + // Mint is at bytes 0-31 in SPL token account layout + account.data[0..32].copy_from_slice(&different_mint.to_bytes()); + context.rpc.set_account(ata_pubkey, account); + + // Second idempotent creation should fail because stored mint != instruction mint + create_and_assert_ata_fails( + &mut context, + Some(compressible_data), + true, + "idempotent_mint_mismatch_second", + 3, // InvalidAccountData + ) + .await; +} + /// Tests creation of an ATA with 0 prepaid epochs (immediately compressible). /// All Light Token accounts now have compression infrastructure, so we pass /// CompressibleData with num_prepaid_epochs: 0. diff --git a/program-tests/compressed-token-test/tests/light_token/transfer.rs b/program-tests/compressed-token-test/tests/light_token/transfer.rs index 5ac4b2d370..a5346b9d14 100644 --- a/program-tests/compressed-token-test/tests/light_token/transfer.rs +++ b/program-tests/compressed-token-test/tests/light_token/transfer.rs @@ -438,6 +438,139 @@ async fn test_ctoken_transfer_mint_mismatch() { .await; } +// ============================================================================ +// Self-Transfer Tests +// ============================================================================ + +#[tokio::test] +async fn test_ctoken_self_transfer_frozen() { + let (mut context, source, _destination, _mint_amount, _source_keypair, _dest_keypair) = + setup_transfer_test(None, 1000).await.unwrap(); + + // Freeze the source account + let mut source_account = context.rpc.get_account(source).await.unwrap().unwrap(); + source_account.data[108] = 2; // AccountState::Frozen + context.rpc.set_account(source, source_account); + + let owner_keypair = context.owner_keypair.insecure_clone(); + + // Self-transfer on frozen account should fail + // from_account_info_checked rejects frozen accounts (state != initialized) + transfer_and_assert_fails( + &mut context, + source, + source, // self-transfer: source == destination + 500, + &owner_keypair, + "self_transfer_frozen", + 3, // InvalidAccountData (from_account_info_checked rejects frozen) + ) + .await; +} + +#[tokio::test] +async fn test_ctoken_self_transfer_insufficient_funds() { + let (mut context, source, _destination, _mint_amount, _source_keypair, _dest_keypair) = + setup_transfer_test(None, 1000).await.unwrap(); + + let owner_keypair = context.owner_keypair.insecure_clone(); + + // Self-transfer with amount > balance should fail with InsufficientFunds + transfer_and_assert_fails( + &mut context, + source, + source, // self-transfer: source == destination + 1500, // more than the 1000 balance + &owner_keypair, + "self_transfer_insufficient_funds", + 6154, // InsufficientFunds + ) + .await; +} + +#[tokio::test] +async fn test_ctoken_self_transfer_success() { + let (mut context, source, _destination, _mint_amount, _source_keypair, _dest_keypair) = + setup_transfer_test(None, 1000).await.unwrap(); + + let owner_keypair = context.owner_keypair.insecure_clone(); + let payer_pubkey = context.payer.pubkey(); + + // Self-transfer with valid amount should succeed + let transfer_ix = build_transfer_instruction(source, source, 500, owner_keypair.pubkey()); + context + .rpc + .create_and_send_transaction( + &[transfer_ix], + &payer_pubkey, + &[&context.payer, &owner_keypair], + ) + .await + .unwrap(); + + // Verify balance unchanged (self-transfer is a no-op) + let source_account = context.rpc.get_account(source).await.unwrap().unwrap(); + let token_account = + spl_token_2022::state::Account::unpack_unchecked(&source_account.data[..165]).unwrap(); + assert_eq!(token_account.amount, 1000); +} + +#[tokio::test] +async fn test_ctoken_transfer_checked_self_transfer_frozen() { + let (mut context, source, _destination, _mint_amount, _source_keypair, _dest_keypair) = + setup_transfer_checked_test_with_spl_mint(None, 1000, 9) + .await + .unwrap(); + + // Freeze the source account + let mut source_account = context.rpc.get_account(source).await.unwrap().unwrap(); + source_account.data[108] = 2; // AccountState::Frozen + context.rpc.set_account(source, source_account); + + let mint = context.mint_pubkey; + let owner_keypair = context.owner_keypair.insecure_clone(); + + // Self-transfer on frozen account should fail + // from_account_info_checked rejects frozen accounts (state != initialized) + transfer_checked_and_assert_fails( + &mut context, + source, + mint, + source, // self-transfer: source == destination + 500, + 9, + &owner_keypair, + "self_transfer_checked_frozen", + 3, // InvalidAccountData (from_account_info_checked rejects frozen) + ) + .await; +} + +#[tokio::test] +async fn test_ctoken_transfer_checked_self_transfer_insufficient_funds() { + let (mut context, source, _destination, _mint_amount, _source_keypair, _dest_keypair) = + setup_transfer_checked_test_with_spl_mint(None, 1000, 9) + .await + .unwrap(); + + let mint = context.mint_pubkey; + let owner_keypair = context.owner_keypair.insecure_clone(); + + // Self-transfer with amount > balance should fail with InsufficientFunds + transfer_checked_and_assert_fails( + &mut context, + source, + mint, + source, // self-transfer: source == destination + 1500, // more than the 1000 balance + 9, + &owner_keypair, + "self_transfer_checked_insufficient_funds", + 6154, // InsufficientFunds + ) + .await; +} + // ============================================================================ // Edge Case Tests // ============================================================================ diff --git a/programs/compressed-token/program/src/ctoken/create.rs b/programs/compressed-token/program/src/ctoken/create.rs index 4da22191ce..04b895f211 100644 --- a/programs/compressed-token/program/src/ctoken/create.rs +++ b/programs/compressed-token/program/src/ctoken/create.rs @@ -92,6 +92,24 @@ pub fn process_create_token_account( // Non-compressible account: token_account must already exist and be owned by CToken program. // Unlike SPL initialize_account3 (which expects System-owned), this expects a pre-existing // CToken-owned account. Ownership is implicitly validated when writing to the account. + // Non-compressible accounts must be exactly BASE_TOKEN_ACCOUNT_SIZE (165 bytes). + if token_account.data_len() != light_token_interface::BASE_TOKEN_ACCOUNT_SIZE as usize { + msg!("Token account data length mismatch"); + return Err(ProgramError::InvalidAccountData); + } + // Verify the account is rent-exempt to prevent garbage collection. + #[cfg(target_os = "solana")] + { + use pinocchio::sysvars::Sysvar; + let rent = pinocchio::sysvars::rent::Rent::get() + .map_err(|_| ProgramError::UnsupportedSysvar)?; + let min_lamports = + rent.minimum_balance(light_token_interface::BASE_TOKEN_ACCOUNT_SIZE as usize); + if token_account.lamports() < min_lamports { + msg!("Token account is not rent-exempt"); + return Err(ProgramError::AccountNotRentExempt); + } + } None }; diff --git a/programs/compressed-token/program/src/ctoken/create_ata.rs b/programs/compressed-token/program/src/ctoken/create_ata.rs index 406981ea0f..d936c5b8cd 100644 --- a/programs/compressed-token/program/src/ctoken/create_ata.rs +++ b/programs/compressed-token/program/src/ctoken/create_ata.rs @@ -3,7 +3,7 @@ use borsh::BorshDeserialize; use light_account_checks::AccountIterator; use light_program_profiler::profile; use light_token_interface::instructions::create_associated_token_account::CreateAssociatedTokenAccountInstructionData; -use pinocchio::{account_info::AccountInfo, instruction::Seed}; +use pinocchio::{account_info::AccountInfo, instruction::Seed, pubkey::pubkey_eq}; use spl_pod::solana_msg::msg; use crate::{ @@ -67,6 +67,17 @@ fn process_create_associated_token_account_with_mode( // If idempotent mode, check if account already exists if IDEMPOTENT && associated_token_account.is_owned_by(&crate::LIGHT_CPI_SIGNER.program_id) { + let token = light_token_interface::state::Token::from_account_info_checked( + associated_token_account, + )?; + if !pubkey_eq(token.base.mint.array_ref(), mint_bytes) { + msg!("Token account mint mismatch"); + return Err(ProgramError::InvalidAccountData); + } + if !pubkey_eq(token.base.owner.array_ref(), owner_bytes) { + msg!("Token account owner mismatch"); + return Err(ProgramError::InvalidAccountData); + } return Ok(()); } diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index e7815a5cd3..0387fa46df 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -52,7 +52,12 @@ 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 validate_self_transfer(source, destination, &accounts[ACCOUNT_AUTHORITY])? { + if validate_self_transfer( + source, + destination, + &accounts[ACCOUNT_AUTHORITY], + instruction_data, + )? { 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 6920561407..e585746e20 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -46,7 +46,12 @@ pub fn process_ctoken_transfer( // 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])? { + if validate_self_transfer( + source, + destination, + &accounts[ACCOUNT_AUTHORITY], + instruction_data, + )? { 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 8fc8d70ba8..43d9dcf462 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/shared.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/shared.rs @@ -17,6 +17,7 @@ use crate::{ /// Validates self-transfer: if source == destination, checks authority is signer /// and is owner or delegate of the token account. +/// Also checks that the account is not frozen and has sufficient funds. /// 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)] @@ -24,11 +25,12 @@ pub fn validate_self_transfer( source: &AccountInfo, destination: &AccountInfo, authority: &AccountInfo, + instruction_data: &[u8], ) -> Result { if !pubkey_eq(source.key(), destination.key()) { return Ok(false); } - validate_self_transfer_authority(source, authority)?; + validate_self_transfer_authority(source, authority, instruction_data)?; Ok(true) } @@ -36,12 +38,22 @@ pub fn validate_self_transfer( fn validate_self_transfer_authority( source: &AccountInfo, authority: &AccountInfo, + instruction_data: &[u8], ) -> Result<(), ProgramError> { if !authority.is_signer() { return Err(ProgramError::MissingRequiredSignature); } + // from_account_info_checked rejects frozen accounts (state != 1) let token = Token::from_account_info_checked(source).map_err(|_| ProgramError::InvalidAccountData)?; + let amount = u64::from_le_bytes( + instruction_data[..8] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ); + if token.base.amount < amount { + return Err(ErrorCode::InsufficientFunds.into()); + } let is_owner = pubkey_eq(authority.key(), token.base.owner.array_ref()); let is_delegate = token .base