diff --git a/program-tests/compressed-token-test/tests/light_token/close.rs b/program-tests/compressed-token-test/tests/light_token/close.rs index 21f83b2bb1..d6183c41ec 100644 --- a/program-tests/compressed-token-test/tests/light_token/close.rs +++ b/program-tests/compressed-token-test/tests/light_token/close.rs @@ -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] @@ -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; - } } diff --git a/programs/compressed-token/program/src/ctoken/close/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs index 05ef6aeb0f..58325a752a 100644 --- a/programs/compressed-token/program/src/ctoken/close/processor.rs +++ b/programs/compressed-token/program/src/ctoken/close/processor.rs @@ -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}; @@ -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, 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] @@ -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 @@ -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();