Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions program-tests/compressed-token-test/tests/light_token/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
133 changes: 133 additions & 0 deletions program-tests/compressed-token-test/tests/light_token/transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
18 changes: 18 additions & 0 deletions programs/compressed-token/program/src/ctoken/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
13 changes: 12 additions & 1 deletion programs/compressed-token/program/src/ctoken/create_ata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -67,6 +67,17 @@ fn process_create_associated_token_account_with_mode<const IDEMPOTENT: bool>(

// 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(());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}

Expand Down
Loading