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
88 changes: 35 additions & 53 deletions program-tests/compressed-token-test/tests/light_token/close.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,41 @@ async fn test_close_compressible_token_account() {
)
.await;
}

// Test 5: Close frozen account (matches SPL Token behavior)
// Frozen accounts CAN be closed as long as they have zero balance.
{
let mut context = setup_account_test_with_created_account(Some((2, false)))
.await
.unwrap();

// Get account, set state to Frozen, set account back
let token_account_pubkey = context.token_account_keypair.pubkey();
let mut account = context
.rpc
.get_account(token_account_pubkey)
.await
.unwrap()
.unwrap();

use light_token_interface::state::token::Token;
use light_zero_copy::traits::ZeroCopyAtMut;
use spl_token_2022::state::AccountState;
let (mut ctoken, _) = Token::zero_copy_at_mut(&mut account.data).unwrap();
ctoken.state = AccountState::Frozen as u8;
drop(ctoken);

context.rpc.set_account(token_account_pubkey, account);

let destination = Keypair::new().pubkey();
context
.rpc
.airdrop_lamports(&destination, 1_000_000)
.await
.unwrap();

close_and_assert_token_account(&mut context, destination, "frozen_account").await;
}
}

#[tokio::test]
Expand Down Expand Up @@ -268,57 +303,4 @@ async fn test_close_token_account_fails() {
)
.await;
}

// Test 11: Frozen account → Error 18036 (TokenError::InvalidAccountState)
{
// Create a fresh account for this test
context.token_account_keypair = Keypair::new();
let compressible_data = CompressibleData {
compression_authority: context.compression_authority,
rent_sponsor,
num_prepaid_epochs: 2,
lamports_per_write: Some(100),
account_version: light_token_interface::state::TokenDataVersion::ShaFlat,
compress_to_pubkey: false,
payer: context.payer.pubkey(),
};
create_and_assert_token_account(&mut context, compressible_data, "frozen_test").await;

// Get account, set state to Frozen (2), set account back
let token_account_pubkey = context.token_account_keypair.pubkey();
let mut account = context
.rpc
.get_account(token_account_pubkey)
.await
.unwrap()
.unwrap();

// Deserialize, modify state to Frozen, serialize back
use light_token_interface::state::token::Token;
use light_zero_copy::traits::ZeroCopyAtMut;
use spl_token_2022::state::AccountState;
let (mut ctoken, _) = Token::zero_copy_at_mut(&mut account.data).unwrap();
ctoken.state = AccountState::Frozen as u8;
drop(ctoken);

// Set the modified account back
context.rpc.set_account(token_account_pubkey, account);

let destination = Keypair::new().pubkey();
context
.rpc
.airdrop_lamports(&destination, 1_000_000)
.await
.unwrap();

close_and_assert_token_account_fails(
&mut context,
destination,
&owner_keypair,
rent_sponsor,
"frozen_account",
18036, // TokenError::InvalidAccountState (frozen accounts rejected by zero_copy_at_mut_checked)
)
.await;
}
}
55 changes: 47 additions & 8 deletions programs/compressed-token/program/src/ctoken/close/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use light_account_checks::{checks::check_signer, AccountInfoTrait};
use light_compressible::rent::AccountRentState;
use light_program_profiler::profile;
use light_token_interface::state::{AccountState, Token, ZTokenMut};
use light_zero_copy::traits::ZeroCopyAtMut;
#[cfg(target_os = "solana")]
use pinocchio::sysvars::Sysvar;
use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq};
Expand All @@ -21,14 +22,54 @@ pub fn process_close_token_account(
// Validate and get accounts
let accounts = CloseTokenAccountAccounts::validate_and_parse(account_infos)?;
{
// Try to parse as CToken using zero-copy deserialization
let ctoken = Token::from_account_info_mut_checked(accounts.token_account)?;
// Parse token account without state validation (allows frozen accounts to be closed)
let ctoken = from_account_info_mut_for_close(accounts.token_account)?;
validate_token_account_close(&accounts, &ctoken)?;
}
close_token_account(&accounts)?;
Ok(())
}

/// Parse token account for close operation.
/// Unlike `from_account_info_mut_checked`, this allows frozen accounts (matching SPL Token behavior).
#[inline(always)]
fn from_account_info_mut_for_close(
account_info: &AccountInfo,
) -> Result<ZTokenMut<'_>, ProgramError> {
// Check program ownership
if !account_info.is_owned_by(&light_token_interface::LIGHT_TOKEN_PROGRAM_ID) {
return Err(ProgramError::IllegalOwner);
}

let mut data = account_info
.try_borrow_mut_data()
.map_err(|_| ProgramError::AccountBorrowFailed)?;

// Extend lifetime - safe because account data lives for transaction duration
let data_slice: &mut [u8] =
unsafe { core::slice::from_raw_parts_mut(data.as_mut_ptr(), data.len()) };

let (token, remaining) =
Token::zero_copy_at_mut(data_slice).map_err(|_| ProgramError::InvalidAccountData)?;

// Check no trailing bytes
if !remaining.is_empty() {
return Err(ProgramError::InvalidAccountData);
}

// Reject uninitialized (state == 0), allow Initialized (1) and Frozen (2)
if token.state == 0 {
return Err(light_token_interface::error::TokenError::InvalidAccountState.into());
}

// Check account type
if !token.is_token_account() {
return Err(ProgramError::InvalidAccountData);
}

Ok(token)
}

/// Validates that a ctoken solana account is ready to be closed.
/// Only the owner or close_authority can close the account.
#[profile]
Expand Down Expand Up @@ -64,13 +105,11 @@ fn validate_token_account_close(
}
// For regular close (!COMPRESS_AND_CLOSE): fall through to owner check

// Check account state - reject frozen and uninitialized (only for regular close)
// Check account state - reject uninitialized
let account_state =
AccountState::try_from(ctoken.state).map_err(|_| ProgramError::UninitializedAccount)?;
match account_state {
AccountState::Initialized => {} // OK to proceed
AccountState::Frozen => return Err(ErrorCode::AccountFrozen.into()),
AccountState::Uninitialized => return Err(ProgramError::UninitializedAccount),
if account_state == AccountState::Uninitialized {
return Err(ProgramError::UninitializedAccount);
}

// For regular close: check close_authority first, then fall back to owner
Expand Down Expand Up @@ -114,7 +153,7 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<(
})?;
// Check for compressible extension and handle lamport distribution

let ctoken = Token::from_account_info_checked(accounts.token_account)?;
let ctoken = from_account_info_mut_for_close(accounts.token_account)?;

// Check for Compressible extension
let compressible = ctoken.get_compressible_extension();
Expand Down
Loading