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
62 changes: 62 additions & 0 deletions program-tests/compressed-token-test/tests/ctoken/create_ata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,68 @@ async fn test_create_ata_failing() {
// Should fail with MissingRequiredSignature (8)
light_program_test::utils::assert::assert_rpc_error(result, 0, 8).unwrap();
}

// Test 10: Arbitrary keypair address instead of correct PDA (non-IDEMPOTENT)
// Tests that providing an arbitrary address (not the correct PDA) fails.
// Currently fails with PrivilegeEscalation (19) at CreateAccount CPI because
// the program tries to sign for a PDA but the account address doesn't match.
// With proper validation (calling validate_ata_derivation in non-IDEMPOTENT mode),
// this would fail earlier with InvalidAccountData (17).
// Error: 19 (PrivilegeEscalation - CPI tries to sign for wrong address)
{
use anchor_lang::prelude::borsh::BorshSerialize;
use light_ctoken_interface::instructions::create_associated_token_account::CreateAssociatedTokenAccountInstructionData;
use solana_sdk::instruction::Instruction;

// Use different mint for this test
context.mint_pubkey = solana_sdk::pubkey::Pubkey::new_unique();

// Get the correct PDA and bump
let (_correct_ata_pubkey, correct_bump) =
derive_ctoken_ata(&context.owner_keypair.pubkey(), &context.mint_pubkey);

// Create an arbitrary keypair (NOT the correct PDA)
let fake_ata_keypair = solana_sdk::signature::Keypair::new();
let fake_ata_pubkey = fake_ata_keypair.pubkey();

// Build instruction with correct bump but WRONG address (arbitrary keypair)
let instruction_data = CreateAssociatedTokenAccountInstructionData {
bump: correct_bump, // Correct bump for the real PDA
compressible_config: None,
};

let mut data = vec![100]; // CreateAssociatedCTokenAccount discriminator
instruction_data.serialize(&mut data).unwrap();

// Account order: owner, mint, payer, ata (fake!), system_program
let ix = Instruction {
program_id: light_compressed_token::ID,
accounts: vec![
solana_sdk::instruction::AccountMeta::new_readonly(
context.owner_keypair.pubkey(),
false,
),
solana_sdk::instruction::AccountMeta::new_readonly(context.mint_pubkey, false),
solana_sdk::instruction::AccountMeta::new(payer_pubkey, true),
solana_sdk::instruction::AccountMeta::new(fake_ata_pubkey, false), // Fake ATA address
solana_sdk::instruction::AccountMeta::new_readonly(
solana_sdk::pubkey::Pubkey::default(),
false,
),
],
data,
};

let result = context
.rpc
.create_and_send_transaction(&[ix], &payer_pubkey, &[&context.payer])
.await;

// Fails with PrivilegeEscalation (19) - program tries to invoke_signed with
// seeds that derive to the correct PDA, but the account passed is a different address.
// Solana runtime rejects this as unauthorized signer privilege escalation.
light_program_test::utils::assert::assert_rpc_error(result, 0, 19).unwrap();
}
}

#[tokio::test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@

3. authority
- (signer)
- Must be the account owner
- For compressible accounts: only owner can close (compression_authority uses Transfer2 CompressAndClose instead)
- Must be the account's close_authority (if set) or owner (if close_authority is None)
- Follows SPL Token behavior: close_authority takes precedence over owner
- For compressible accounts: only owner/close_authority can close (compression_authority uses Transfer2 CompressAndClose instead)

4. rent_sponsor (required for compressible accounts)
- (mutable)
Expand Down Expand Up @@ -75,9 +76,11 @@
- If account has extensions vector with `ZExtensionStructMut::Compressible`:
- Get rent_sponsor from accounts (returns error if missing)
- Verify compressible_ext.rent_sponsor == rent_sponsor.key()
- Fall through to owner check (compression_authority cannot use this instruction)
- Verify authority.key() == compressed_token.owner (returns `ErrorCode::OwnerMismatch` if not)
- **Note:** For CompressAndClose mode in Transfer2, compression_authority validation is done separately
- Fall through to close_authority/owner check (compression_authority cannot use this instruction)
- Check close_authority field (SPL Token compatible behavior):
- If close_authority is Some: verify authority.key() == close_authority (returns `ErrorCode::OwnerMismatch` if not)
- If close_authority is None: verify authority.key() == compressed_token.owner (returns `ErrorCode::OwnerMismatch` if not)
- **Note:** For CompressAndClose mode in Transfer2, compression_authority validation is done separately (close_authority check does not apply)

4. **Distribute lamports** (`close_token_account_inner`):
4.1. **Setup**:
Expand Down Expand Up @@ -133,7 +136,9 @@
- `ProgramError::InsufficientFunds` (error code: 6) - Insufficient funds for lamport transfer during rent calculation

**Edge Cases and Considerations:**
- Only the owner can use this instruction (CloseTokenAccount)
- Only the close_authority (if set) or owner (if close_authority is None) can use this instruction (CloseTokenAccount)
- This matches SPL Token behavior where close_authority takes precedence over owner
- **Note:** SetAuthority instruction to set close_authority is currently unimplemented; close_authority is always None on newly created accounts
- For compression_authority to close accounts, use CompressAndClose mode in Transfer2
- Compressible accounts require 4 accounts, non-compressible require only 3
- Balance must be zero for this instruction (use Transfer2 CompressAndClose to compress non-zero balances)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,28 @@ fn validate_token_account<const COMPRESS_AND_CLOSE: bool>(
return Err(ProgramError::InvalidAccountData);
}

// For regular close: verify authority matches owner
if !pubkey_eq(ctoken.owner.array_ref(), accounts.authority.key()) {
msg!(
"owner mismatch: ctoken.owner {:?} != {:?} authority",
solana_pubkey::Pubkey::from(ctoken.owner.to_bytes()),
solana_pubkey::Pubkey::from(*accounts.authority.key())
);
return Err(ErrorCode::OwnerMismatch.into());
// For regular close: check close_authority first, then fall back to owner
// This matches SPL Token behavior where close_authority takes precedence over owner
if let Some(close_authority) = ctoken.close_authority.as_ref() {
// close_authority is set - only close_authority can close
if !pubkey_eq(close_authority.array_ref(), accounts.authority.key()) {
msg!(
"close authority mismatch: close_authority {:?} != {:?} authority",
solana_pubkey::Pubkey::from(close_authority.to_bytes()),
solana_pubkey::Pubkey::from(*accounts.authority.key())
);
return Err(ErrorCode::OwnerMismatch.into());
}
} else {
// close_authority is None - owner can close
if !pubkey_eq(ctoken.owner.array_ref(), accounts.authority.key()) {
msg!(
"owner mismatch: ctoken.owner {:?} != {:?} authority",
solana_pubkey::Pubkey::from(ctoken.owner.to_bytes()),
solana_pubkey::Pubkey::from(*accounts.authority.key())
);
return Err(ErrorCode::OwnerMismatch.into());
}
}
Ok(false)
}
Expand Down