diff --git a/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs b/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs index b7e1de9afd..49cb19a9e7 100644 --- a/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs +++ b/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs @@ -12,45 +12,60 @@ use super::shared::*; #[tokio::test] #[serial] async fn test_compress_and_close_owner_scenarios() { - // Test 1: Owner closes account with token balance + // Test 1: Owner cannot close compressible account with token balance + // Only compression_authority can compress and close compressible accounts { let mut context = setup_compress_and_close_test( 2, // 2 prepaid epochs 1000, // 1000 token balance - None, // No time warp needed for owner + None, // No time warp needed false, // Use default rent sponsor ) .await .unwrap(); - compress_and_close_owner_and_assert( + // Clone owner keypair before mutable borrow + let owner_keypair = context.owner_keypair.insecure_clone(); + + // Owner trying to compress and close should fail with InvalidAccountData + compress_and_close_and_assert_fails( &mut context, + &owner_keypair, None, // Default destination (owner) - "owner_with_balance", + "owner_with_balance_should_fail", + 3, // ProgramError::InvalidAccountData ) .await; } - // Test 2: Owner closes account with zero balance + // Test 2: Owner cannot close compressible account with zero balance + // Only compression_authority can compress and close compressible accounts { let mut context = setup_compress_and_close_test( 2, // 2 prepaid epochs 0, // Zero token balance - None, // No time warp needed for owner + None, // No time warp needed false, // Use default rent sponsor ) .await .unwrap(); - compress_and_close_owner_and_assert( + // Clone owner keypair before mutable borrow + let owner_keypair = context.owner_keypair.insecure_clone(); + + // Owner trying to compress and close should fail with InvalidAccountData + compress_and_close_and_assert_fails( &mut context, + &owner_keypair, None, // Default destination (owner) - "owner_zero_balance", + "owner_zero_balance_should_fail", + 3, // ProgramError::InvalidAccountData ) .await; } - // Test 3: Owner closes regular 165-byte ctoken account (no compressible extension) + // Test 3: Owner cannot close regular 165-byte ctoken account (no compressible extension) + // Non-compressible accounts cannot use compress_and_close { let mut context = setup_account_test().await.unwrap(); @@ -76,16 +91,55 @@ async fn test_compress_and_close_owner_scenarios() { .unwrap(); context.rpc.set_account(token_account_pubkey, token_account); - // Compress and close as owner - compress_and_close_owner_and_assert( - &mut context, - None, // Default destination (owner) - "owner_non_compressible", + let payer_pubkey = context.payer.pubkey(); + + // Get output queue for compression + let output_queue = context + .rpc + .get_random_state_tree_info() + .unwrap() + .get_output_pubkey() + .unwrap(); + + // Create compress_and_close instruction with is_compressible=false for non-compressible account + use light_token_client::instructions::transfer2::{ + create_generic_transfer2_instruction, CompressAndCloseInput, Transfer2InstructionType, + }; + + let compress_and_close_ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::CompressAndClose( + CompressAndCloseInput { + solana_ctoken_account: token_account_pubkey, + authority: context.owner_keypair.pubkey(), + output_queue, + destination: None, + is_compressible: false, // Non-compressible account + }, + )], + payer_pubkey, + false, ) - .await; + .await + .unwrap(); + + // Execute transaction expecting failure + let result = context + .rpc + .create_and_send_transaction( + &[compress_and_close_ix], + &payer_pubkey, + &[&context.payer, &context.owner_keypair], + ) + .await; + + // Assert that the transaction failed with InvalidAccountData (error code 3) + // "compress and close requires compressible extension" + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); } - // Test 4: Owner closes associated token account + // Test 4: Owner cannot close compressible associated token account + // Only compression_authority can compress and close compressible accounts { let mut context = setup_account_test().await.unwrap(); let payer_pubkey = context.payer.pubkey(); @@ -127,7 +181,6 @@ async fn test_compress_and_close_owner_scenarios() { context.rpc.set_account(ata_pubkey, ata_account); // Create compress_and_close instruction manually for ATA - use light_test_utils::assert_transfer2::assert_transfer2_compress_and_close; use light_token_client::instructions::transfer2::{ create_generic_transfer2_instruction, CompressAndCloseInput, Transfer2InstructionType, }; @@ -156,27 +209,18 @@ async fn test_compress_and_close_owner_scenarios() { .await .unwrap(); - context + // Owner trying to compress and close ATA should fail with InvalidAccountData + let result = context .rpc .create_and_send_transaction( &[compress_and_close_ix], &payer_pubkey, &[&context.payer, &context.owner_keypair], ) - .await - .unwrap(); + .await; - assert_transfer2_compress_and_close( - &mut context.rpc, - CompressAndCloseInput { - solana_ctoken_account: ata_pubkey, - authority: context.owner_keypair.pubkey(), - output_queue, - destination: None, - is_compressible: true, - }, - ) - .await; + // Assert that the transaction failed with InvalidAccountData (error code 3) + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); } } @@ -365,13 +409,14 @@ async fn test_compress_and_close_rent_authority_scenarios() { #[tokio::test] #[serial] async fn test_compress_and_close_compress_to_pubkey() { - // Test 9: compress_to_pubkey=true, account pubkey becomes owner in compressed output (PDA use case) + // Test: compress_to_pubkey=true, account pubkey becomes owner in compressed output (PDA use case) + // Uses compression_authority (forester) since owner cannot compress and close compressible accounts { let mut context = setup_compress_and_close_test( - 2, // 2 prepaid epochs - 500, // 500 token balance - None, // No time warp needed for owner - false, // Use default rent sponsor + 2, // 2 prepaid epochs + 500, // 500 token balance + Some(2), // Warp to epoch 2 (makes account compressible for forester) + false, // Use default rent sponsor ) .await .unwrap(); @@ -405,11 +450,42 @@ async fn test_compress_and_close_compress_to_pubkey() { // Write the modified account back context.rpc.set_account(token_account_pubkey, token_account); - // Execute compress_and_close using helper - compress_and_close_owner_and_assert( - &mut context, - None, // Default destination (owner) - "compress_to_pubkey_true", + // Get forester keypair + let forester_keypair = context.rpc.test_accounts.protocol.forester.insecure_clone(); + + // Create destination for compression incentive + let destination = Keypair::new(); + context + .rpc + .airdrop_lamports(&destination.pubkey(), 1_000_000) + .await + .unwrap(); + + // Compress and close using rent authority (forester) + compress_and_close_forester( + &mut context.rpc, + &[token_account_pubkey], + &forester_keypair, + &context.payer, + Some(destination.pubkey()), + ) + .await + .unwrap(); + + // Assert compress and close succeeded - the owner in compressed output should be the token_account_pubkey + use light_test_utils::assert_transfer2::assert_transfer2_compress_and_close; + use light_token_client::instructions::transfer2::CompressAndCloseInput; + + let output_queue = context.rpc.get_random_state_tree_info().unwrap().queue; + assert_transfer2_compress_and_close( + &mut context.rpc, + CompressAndCloseInput { + solana_ctoken_account: token_account_pubkey, + authority: context.compression_authority, + output_queue, + destination: Some(destination.pubkey()), + is_compressible: true, + }, ) .await; } diff --git a/program-tests/compressed-token-test/tests/ctoken/shared.rs b/program-tests/compressed-token-test/tests/ctoken/shared.rs index 05dc20ac0d..155d41bc44 100644 --- a/program-tests/compressed-token-test/tests/ctoken/shared.rs +++ b/program-tests/compressed-token-test/tests/ctoken/shared.rs @@ -587,89 +587,6 @@ pub async fn setup_compress_and_close_test( Ok(context) } -/// Compress and close account as owner and assert success -/// -/// # Parameters -/// - `context`: Test context with RPC and account info -/// - `destination`: Optional destination for user funds (defaults to owner) -/// - `name`: Test name for debugging -pub async fn compress_and_close_owner_and_assert( - context: &mut AccountTestContext, - destination: Option, - name: &str, -) { - use light_ctoken_types::COMPRESSIBLE_TOKEN_ACCOUNT_SIZE; - use light_test_utils::assert_transfer2::assert_transfer2_compress_and_close; - use light_token_client::instructions::transfer2::{ - create_generic_transfer2_instruction, CompressAndCloseInput, Transfer2InstructionType, - }; - - println!("Compress and close (owner) initiated for: {}", name); - - let payer_pubkey = context.payer.pubkey(); - let token_account_pubkey = context.token_account_keypair.pubkey(); - let owner_pubkey = context.owner_keypair.pubkey(); - - // Check if account is compressible by checking size - let account_info = context - .rpc - .get_account(token_account_pubkey) - .await - .unwrap() - .unwrap(); - let is_compressible = account_info.data.len() == COMPRESSIBLE_TOKEN_ACCOUNT_SIZE as usize; - - // Get output queue for compression - let output_queue = context - .rpc - .get_random_state_tree_info() - .unwrap() - .get_output_pubkey() - .unwrap(); - - // Create compress_and_close instruction as owner - let compress_and_close_ix = create_generic_transfer2_instruction( - &mut context.rpc, - vec![Transfer2InstructionType::CompressAndClose( - CompressAndCloseInput { - solana_ctoken_account: token_account_pubkey, - authority: owner_pubkey, - output_queue, - destination, - is_compressible, - }, - )], - payer_pubkey, - false, - ) - .await - .unwrap(); - - // Execute transaction - context - .rpc - .create_and_send_transaction( - &[compress_and_close_ix], - &payer_pubkey, - &[&context.payer, &context.owner_keypair], - ) - .await - .unwrap(); - - // Assert compress and close succeeded - assert_transfer2_compress_and_close( - &mut context.rpc, - CompressAndCloseInput { - solana_ctoken_account: token_account_pubkey, - authority: owner_pubkey, - output_queue, - destination, - is_compressible, - }, - ) - .await; -} - /// Compress and close account expecting failure with custom authority /// /// # Parameters diff --git a/program-tests/compressed-token-test/tests/transfer2/functional.rs b/program-tests/compressed-token-test/tests/transfer2/functional.rs index 59a99de8f6..eb89504eec 100644 --- a/program-tests/compressed-token-test/tests/transfer2/functional.rs +++ b/program-tests/compressed-token-test/tests/transfer2/functional.rs @@ -2,8 +2,8 @@ use light_ctoken_types::state::TokenDataVersion; use serial_test::serial; use crate::transfer2::shared::{ - MetaApproveInput, MetaCompressAndCloseInput, MetaCompressInput, MetaDecompressInput, - MetaTransfer2InstructionType, MetaTransferInput, TestCase, TestConfig, TestContext, + MetaApproveInput, MetaCompressInput, MetaDecompressInput, MetaTransfer2InstructionType, + MetaTransferInput, TestCase, TestConfig, TestContext, }; // Basic Transfer Operations @@ -173,13 +173,6 @@ async fn test_transfer2_functional() { test52_transfer_multiple_compressions(), test53_transfer_multiple_decompressions(), test54_transfer_compress_decompress_balanced(), - test55_compress_and_close_as_owner(), - test55_compress_and_close_as_owner_compressible(), - test56_compress_and_close_with_destination(), - test57_multiple_compress_and_close(), - test58_compress_and_close_with_transfer(), - test59_compress_and_close_full_balance(), - test60_compress_and_close_specific_output(), // Delegate Operations (61-66) test61_approve_with_change(), test62_delegate_transfer_single_input(), @@ -2178,151 +2171,6 @@ fn test72_multiple_pools_same_mint() -> TestCase { } } -// ============================================================================ -// CompressAndClose Operation Tests (55-60) -// ============================================================================ - -// Test 55: CompressAndClose as owner (not compressible) -fn test55_compress_and_close_as_owner() -> TestCase { - TestCase { - name: "CompressAndClose as owner (no validation needed)".to_string(), - actions: vec![MetaTransfer2InstructionType::CompressAndClose( - MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, // Must be ShaFlat for security - signer_index: 0, // Owner who signs and owns the CToken ATA - destination_index: None, // No destination = authority receives rent - mint_index: 0, // Use first mint - is_compressible: false, // Regular CToken ATA, no extensions - }, - )], - } -} -// Test 55: CompressAndClose as owner (compressible) -fn test55_compress_and_close_as_owner_compressible() -> TestCase { - TestCase { - name: "CompressAndClose as owner (no validation needed)".to_string(), - actions: vec![MetaTransfer2InstructionType::CompressAndClose( - MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, // Must be ShaFlat for security - signer_index: 0, // Owner who signs and owns the CToken ATA - destination_index: None, // No destination = authority receives rent - mint_index: 0, // Use first mint - is_compressible: true, // Regular CToken ATA, no extensions - }, - )], - } -} - -// Test 56: CompressAndClose with destination -fn test56_compress_and_close_with_destination() -> TestCase { - TestCase { - name: "CompressAndClose with destination (rent to specific recipient)".to_string(), - actions: vec![MetaTransfer2InstructionType::CompressAndClose( - MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, // Must be ShaFlat for security - signer_index: 0, // Owner who signs and owns the CToken ATA - destination_index: Some(1), // Send rent lamports to keypair[1] - mint_index: 0, // Use first mint - is_compressible: true, // Compressible account with extensions - }, - )], - } -} - -// Test 57: Multiple CompressAndClose in single transaction -fn test57_multiple_compress_and_close() -> TestCase { - TestCase { - name: "Multiple CompressAndClose in single transaction".to_string(), - actions: vec![ - // Close first account from signer 0 - MetaTransfer2InstructionType::CompressAndClose(MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, - signer_index: 0, // First owner - destination_index: None, // Rent back to authority - mint_index: 0, - is_compressible: true, - }), - // Close second account from signer 1 - MetaTransfer2InstructionType::CompressAndClose(MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, - signer_index: 1, // Second owner - destination_index: None, // Rent back to authority - mint_index: 0, - is_compressible: true, - }), - // Close third account from signer 2 - MetaTransfer2InstructionType::CompressAndClose(MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, - signer_index: 2, // Third owner - destination_index: None, // Rent back to authority - mint_index: 0, - is_compressible: true, - }), - ], - } -} - -// Test 58: CompressAndClose + regular transfer in same transaction -fn test58_compress_and_close_with_transfer() -> TestCase { - TestCase { - name: "CompressAndClose + regular transfer in same transaction".to_string(), - actions: vec![ - // First: Close CToken account from signer 0 - MetaTransfer2InstructionType::CompressAndClose(MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, - signer_index: 0, // Owner who closes - destination_index: None, // Rent back to authority - mint_index: 0, - is_compressible: true, - }), - // Second: Regular compressed transfer from signer 1 to signer 2 - MetaTransfer2InstructionType::Transfer(MetaTransferInput { - input_compressed_accounts: vec![500], // One account with 500 tokens - amount: 300, - is_delegate_transfer: false, - token_data_version: TokenDataVersion::ShaFlat, - signer_index: 1, // Different signer than CompressAndClose - delegate_index: None, - recipient_index: 2, // Transfer to keypair[2] - change_amount: None, // Keep 200 as change - mint_index: 0, - }), - ], - } -} - -// Test 59: CompressAndClose with full balance -fn test59_compress_and_close_full_balance() -> TestCase { - TestCase { - name: "CompressAndClose with full balance (compress all tokens before closing)".to_string(), - actions: vec![MetaTransfer2InstructionType::CompressAndClose( - MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, // Must be ShaFlat for security - signer_index: 0, // Owner who signs and owns the CToken ATA - destination_index: None, // Rent back to authority - mint_index: 0, // Use first mint - is_compressible: true, // Compressible account with extensions - }, - )], - } -} - -// Test 60: CompressAndClose creating specific output (rent authority case) -fn test60_compress_and_close_specific_output() -> TestCase { - TestCase { - name: "CompressAndClose creating specific output (rent authority case)".to_string(), - actions: vec![MetaTransfer2InstructionType::CompressAndClose( - MetaCompressAndCloseInput { - token_data_version: TokenDataVersion::ShaFlat, // Must be ShaFlat for security - signer_index: 0, // Owner who signs and owns the CToken ATA - destination_index: Some(2), // Send rent lamports to specific recipient (keypair[2]) - mint_index: 0, // Use first mint - is_compressible: true, // Compressible account with extensions - }, - )], - } -} - // ============================================================================ // Delegate Operation Tests (61-62) // ============================================================================ diff --git a/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs b/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs index a5b8da53d5..5ed208c0f9 100644 --- a/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs +++ b/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs @@ -16,6 +16,7 @@ use light_compressed_token_sdk::{ ValidityProof, }; use light_ctoken_types::instructions::transfer2::{Compression, MultiTokenTransferOutputData}; +use light_program_test::utils::assert::assert_rpc_error; pub use light_program_test::{LightProgramTest, ProgramTestConfig}; pub use light_test_utils::{ airdrop_lamports, @@ -205,7 +206,7 @@ async fn test_spl_to_ctoken_transfer() { } #[tokio::test] -async fn test_ctoken_to_spl_with_compress_and_close() { +async fn test_failing_ctoken_to_spl_with_compress_and_close() { let mut rpc = LightProgramTest::new(ProgramTestConfig::new(true, None)) .await .unwrap(); @@ -312,54 +313,10 @@ async fn test_ctoken_to_spl_with_compress_and_close() { .unwrap(); // Execute transaction - rpc.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer, &recipient]) - .await - .unwrap(); - - // Verify final balances - { - // Verify SPL token balance is restored - let spl_account_data = rpc - .get_account(spl_token_account_keypair.pubkey()) - .await - .unwrap() - .unwrap(); - let spl_account = spl_pod::bytemuck::pod_from_bytes::(&spl_account_data.data) - .map_err(|e| { - RpcError::AssertRpcError(format!("Failed to parse SPL token account: {}", e)) - }) - .unwrap(); - let restored_spl_balance: u64 = spl_account.amount.into(); - assert_eq!( - restored_spl_balance, amount, - "SPL token balance should be restored to original amount" - ); - } - - { - // Verify CToken account is CLOSED (not just balance = 0) - let ctoken_account_result = rpc.get_account(associated_token_account).await.unwrap(); - match ctoken_account_result { - None => { - println!("✓ CToken account successfully closed (account does not exist)"); - } - Some(account_data) => { - assert_eq!( - account_data.data.len(), - 0, - "CToken account data should be empty after CompressAndClose" - ); - assert_eq!( - account_data.lamports, 0, - "CToken account lamports should be 0 after CompressAndClose" - ); - println!("✓ CToken account successfully closed (zeroed out)"); - } - } - } - - println!("✓ Successfully completed CToken -> SPL transfer with CompressAndClose"); - println!(" This validates owner can use CompressAndClose without explicit compressed_token_account validation"); + let result = rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer, &recipient]) + .await; + assert_rpc_error(result, 0, 3).unwrap(); } pub struct CtokenToSplTransferAndClose { diff --git a/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md b/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md index 307034697f..f2ab7c15db 100644 --- a/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md +++ b/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md @@ -9,9 +9,10 @@ 2. Account layout `CToken` is defined in path: program-libs/ctoken-types/src/state/ctoken/ctoken_struct.rs 3. Supports both regular (non-compressible) and compressible token accounts (with compressible extension) 4. For compressible accounts (with compressible extension): - - Rent exemption is returned to the rent recipient (destination account) - - Write top-up lamports are returned to the authority (original fee payer) - - Authority can be either the owner OR the rent authority (if account is compressible) + - Rent exemption + rent lamports are returned to the rent_sponsor + - Remaining lamports are returned to the destination account + - Only the owner can close using this instruction (balance must be zero) + - **Note:** To compress and close with non-zero balance, use CompressAndClose mode in Transfer2 (compression_authority only) 5. For non-compressible accounts: - All lamports are transferred to the destination account - Only the owner can close the account @@ -36,10 +37,8 @@ 3. authority - (signer) - - Either the account owner OR rent authority (for compressible accounts) - - For compressible accounts closed by rent authority: - - Account must be compressible (past rent expiry) - - Authority must match compression_authority in extension + - Must be the account owner + - For compressible accounts: only owner can close (compression_authority uses Transfer2 CompressAndClose instead) 4. rent_sponsor (required for compressible accounts) - (mutable) @@ -73,19 +72,12 @@ - Verify amount == 0 (non-zero returns `ErrorCode::NonNativeHasBalance`) 3.3. **Authority validation**: - - Check if compressed_token.owner == authority.key() (store as `owner_matches`) - - If account has extensions vector: - 3.3.1. Iterate through extensions looking for `ZExtensionStructMut::Compressible` - 3.3.2. If compressible extension found: - - Get rent_sponsor from accounts (returns error if missing) - - Verify compressible_ext.rent_sponsor == rent_sponsor.key() - - If not owner_matches and CHECK_RENT_AUTH=true: - - Verify compressible_ext.compression_authority == authority.key() - - Get current slot from Clock sysvar - - Call `compressible_ext.is_compressible(data_len, current_slot, lamports)` - - If not compressible: return error - - Return Ok((true, compress_to_pubkey_flag)) - - If owner doesn't match and no valid rent authority: return `ErrorCode::OwnerMismatch` + - 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 4. **Distribute lamports** (`close_token_account_inner`): 4.1. **Setup**: @@ -110,13 +102,10 @@ - base_rent, lamports_per_byte_per_epoch, compression_cost - Returns (lamports_to_rent_sponsor, lamports_to_destination) - Get rent_sponsor account from accounts (error if missing) - - Special case: if authority.key() == compression_authority: - - Extract compression incentive from lamports_to_rent_sponsor - - Add lamports_to_destination to lamports_to_rent_sponsor - - Set lamports_to_destination = compression_cost (goes to forester) - Transfer lamports_to_rent_sponsor to rent_sponsor via `transfer_lamports` (if > 0) - Transfer lamports_to_destination to destination via `transfer_lamports` (if > 0) - Return early (skip non-compressible path) + - **Note:** Compression incentive logic only applies when compression_authority closes via Transfer2 CompressAndClose 4.4. **For non-compressible accounts**: - Transfer all token_account.lamports to destination via `transfer_lamports` @@ -140,13 +129,14 @@ - `ErrorCode::AccountFrozen` (error code: 6076) - Account state is Frozen - `ProgramError::UninitializedAccount` (error code: 10) - Account state is Uninitialized or invalid - `ErrorCode::NonNativeHasBalance` (error code: 6074) - Account has non-zero token balance -- `ErrorCode::OwnerMismatch` (error code: 6075) - Authority doesn't match owner and isn't valid rent authority +- `ErrorCode::OwnerMismatch` (error code: 6075) - Authority doesn't match owner - `ProgramError::InsufficientFunds` (error code: 6) - Insufficient funds for lamport transfer during rent calculation **Edge Cases and Considerations:** -- When rent authority closes an account, all funds (including user funds) go to rent_sponsor +- Only the owner can use this instruction (CloseTokenAccount) +- For compression_authority to close accounts, use CompressAndClose mode in Transfer2 - Compressible accounts require 4 accounts, non-compressible require only 3 -- The timing check for compressibility uses current slot vs last_claimed_slot +- Balance must be zero for this instruction (use Transfer2 CompressAndClose to compress non-zero balances) - The instruction handles accounts with no extensions gracefully (non-compressible path) - Zero-lamport accounts are handled without attempting transfers - Separation of rent_sponsor from destination allows users to specify where their funds go while ensuring rent goes to the protocol diff --git a/programs/compressed-token/program/docs/instructions/TRANSFER2.md b/programs/compressed-token/program/docs/instructions/TRANSFER2.md index aa199077de..1cc1d30166 100644 --- a/programs/compressed-token/program/docs/instructions/TRANSFER2.md +++ b/programs/compressed-token/program/docs/instructions/TRANSFER2.md @@ -8,8 +8,7 @@ | Only compress/decompress (no transfers) | → [Path A](#path-a-no-compressed-accounts-compressions-only-operations) (line 134) + [Compressions-only accounts](#compressions-only-accounts-when-no_compressed_accounts) (line 99) | | Compress SPL tokens | → [SPL compression](#spl-token-compressiondecompression) (line 217) | | Compress CToken accounts | → [CToken compression](#ctoken-compressiondecompression-srctransfer2compressionctoken) (line 227) | -| Close account as **owner** | → [CompressAndClose](#for-compressandclose) (line 243) - no validation needed | -| Close account as **rent authority** | → [Rent authority rules](#design-principle-ownership-separation) (line 244) + `compressible/docs/RENT.md` | +| Close compressible account (forester) | → [CompressAndClose](#for-compressandclose) (line 243) - compression_authority only | | Use CPI context | → [Write mode](#cpi-context-write-path) (line 192) or [Execute mode](#cpi-context-support-for-cross-program-invocations) (line 27) | | Debug errors | → [Error reference](#errors) (line 275) | @@ -28,7 +27,7 @@ 3. Compression modes: - `Compress`: Move tokens from Solana account (ctoken or SPL) to compressed state - `Decompress`: Move tokens from compressed state to Solana account (ctoken or SPL) - - `CompressAndClose`: Compress full ctoken balance and close the account (authority: owner or rent authority for compressible accounts) + - `CompressAndClose`: Compress full ctoken balance and close the account (authority: compression_authority only, requires compressible extension, **ctoken accounts only - NOT supported for SPL tokens**) 4. Global sum check enforces transaction balance: - Input sum = compressed inputs + compress operations (tokens entering compressed state) @@ -161,7 +160,7 @@ Packed accounts (dynamic indexing): c. **Close accounts for CompressAndClose operations:** - After compression validation succeeds, close the token accounts: - - Lamport distribution via `compressible::calculate_close_lamports`: + - Lamport distribution via `AccountRentState::calculate_close_distribution()`: - Rent exemption + completed epoch rent → rent_sponsor account - Unutilized rent (partial epoch) → destination account - Compression incentive → forester (when rent authority closes) @@ -212,8 +211,8 @@ Packed accounts (dynamic indexing): **Compression/Decompression Processing Details:** **Key distinction between compression modes:** -- **Compress/Decompress:** Only participate in sum checks - tokens are added/subtracted from running sums per mint, ensuring overall balance but no specific output validation -- **CompressAndClose:** Validates a specific compressed token account exists in outputs that mirrors the account being closed (same mint, amount equals full balance, owner preserved or set to account pubkey, no delegate - delegation not implemented for ctoken accounts) +- **Compress/Decompress:** Participate in sum checks - tokens are added/subtracted from running sums per mint, ensuring overall balance but no specific output validation +- **CompressAndClose:** Participates in sum checks (like Compress) AND additionally validates a specific compressed token account exists in outputs that mirrors the account being closed (same mint, amount equals full balance, owner preserved or set to account pubkey, no delegate). The output validation happens IN ADDITION to sum checks, providing extra security for account closure. When compression processing occurs (in both Path A and Path B): @@ -256,31 +255,40 @@ When compression processing occurs (in both Path A and Path B): - **For CompressAndClose:** - **Authority validation:** - Authority must be signer - - Authority must be either token account owner OR rent authority (for compressible accounts) - - **Design principle: Ownership separation** (see `program-libs/compressible/docs/RENT.md` for detailed rent calculations) - - Tokens: Belong to the owner who can compress them freely - - Rent exemption + completed epoch rent: Belong to rent authority (who funded them) - - Unutilized rent (partial current epoch): Returns to user/destination - - Compression incentive: Goes to forester when rent authority compresses + - Authority must be the compression_authority (from compressible extension) + - Owner CANNOT use CompressAndClose - only compression_authority can + - Non-compressible accounts (without Compressible extension) CANNOT use CompressAndClose + - **Compressibility timing check** (required gate for compression_authority): + - Calls `compressible_ext.is_compressible(data_len, current_slot, lamports)` + - Returns `Some(_)` if account can be compressed, `None` if not yet compressible + - Account becomes compressible when it lacks sufficient rent for current epoch + 1 + - This prevents compression_authority from arbitrarily compressing accounts before rent expires + - Error: `ProgramError::InvalidAccountData` with message "account not compressible" if check fails + - **Frozen account check:** + - Frozen ctoken accounts (state == 2) cannot be compressed + - Error: `ErrorCode::AccountFrozen` if account is frozen + - **Design principle: Compression authority control** (see `program-libs/compressible/docs/RENT.md` for detailed rent calculations) + - Tokens: Belong to the owner, but compression is controlled by compression_authority + - Rent exemption + completed epoch rent: Belong to rent_sponsor (who funded them) + - Compression incentive: Goes to forester (destination) when compression_authority compresses - **Compressibility determination** (via `compressible::calculate_rent_and_balance`): - Account becomes compressible when it lacks rent for current epoch + 1 - - Rent authority can only compress when `is_compressible()` returns true + - Compression_authority can only compress when `is_compressible()` returns true - See `program-libs/compressible/docs/` for complete rent system documentation - - When **owner** closes: No compressed output validation required (owner controls their tokens, sum check ensures balance) - - When **rent authority** closes: Must validate compressed output exactly preserves owner's tokens - - **Compressed token account validation (only when rent authority closes) - MUST exist in outputs with:** + - **Compressed token account validation - MUST exist in outputs with:** - Amount: Must exactly match the full token account balance being compressed - - Owner: If compress_to_pubkey flag is false, owner must match original token account owner - - Owner: If compress_to_pubkey flag is true, owner must be the token account's pubkey (allows closing accounts owned by PDAs) - - **Note:** compress_to_pubkey validation ONLY applies when rent authority closes. When owner closes, no output validation occurs (owner has full control, sum check ensures balance preservation) + - Owner: If `compress_to_pubkey` flag is false, owner must match original token account owner + - Owner: If `compress_to_pubkey` flag is true, owner must be the token account's pubkey (allows closing accounts owned by PDAs) + - **Note:** `compress_to_pubkey` is stored in the compressible extension and set during account creation, not per-instruction + - Mint: Must match the ctoken account's mint field - Delegate: Must be None (has_delegate=false and delegate=0) - delegates cannot be carried over - Version: Must be ShaFlat (version=3) for security - Version: Must match the version specified in the token account's compressible extension - **Account state updates:** - Token account balance is set to 0 - Account is marked for closing after the transaction - - **Security guarantee:** Unlike Compress which only adds to sum checks, CompressAndClose ensures the exact compressed account exists, preventing token loss or misdirection - - **Uniqueness validation:** All CompressAndClose operations in a single instruction must use different compressed output account indices. Duplicate output indices are rejected to prevent fund theft attacks where a rent authority could close multiple accounts but route all funds to a single compressed output + - **Security guarantee:** CompressAndClose ensures the exact compressed account exists, preventing token loss or misdirection. Only compression_authority can initiate, protecting users from unauthorized compression. + - **Uniqueness validation:** All CompressAndClose operations in a single instruction must use different compressed output account indices. Duplicate output indices are rejected to prevent fund theft attacks where a compression_authority could close multiple accounts but route all funds to a single compressed output - Calculate compressible extension top-up if present (returns Option) - **Transfer deduplication optimization:** - Collects all transfers into a 40-element array indexed by account @@ -317,9 +325,13 @@ When compression processing occurs (in both Path A and Path B): - `ErrorCode::CpiContextExpected` (error code: 6085) - CPI context required but not provided - `ErrorCode::CompressAndCloseDestinationMissing` (error code: 6087) - Missing destination for CompressAndClose - `ErrorCode::CompressAndCloseAuthorityMissing` (error code: 6088) - Missing authority for CompressAndClose -- `ErrorCode::CompressAndCloseAmountMismatch` (error code: 6090) - CompressAndClose amount doesn't match balance -- `ErrorCode::CompressAndCloseDelegateNotAllowed` (error code: 6092) - Delegates cannot use CompressAndClose +- `ErrorCode::CompressAndCloseInvalidOwner` (error code: 6089) - Compressed token owner does not match expected owner (source ctoken.owner or token_account.pubkey if compress_to_pubkey) +- `ErrorCode::CompressAndCloseAmountMismatch` (error code: 6090) - Compression amount must match the full token balance +- `ErrorCode::CompressAndCloseBalanceMismatch` (error code: 6091) - Token account balance must match compressed output amount +- `ErrorCode::CompressAndCloseDelegateNotAllowed` (error code: 6092) - Source token account has delegate OR compressed output has delegate (delegates not supported) +- `ErrorCode::CompressAndCloseInvalidVersion` (error code: 6093) - Compressed token version must be 3 (ShaFlat) and must match compressible extension's account_version - `ErrorCode::CompressAndCloseDuplicateOutput` (error code: 6420) - Cannot use the same compressed output account for multiple CompressAndClose operations (security protection against fund theft) +- `ErrorCode::CompressAndCloseOutputMissing` (error code: 6421) - Compressed token account output required but not provided - `AccountError::InvalidSigner` (error code: 12015) - Required signer account is not signing - `AccountError::AccountNotMutable` (error code: 12008) - Required mutable account is not mutable - Additional errors from close_token_account for CompressAndClose operations diff --git a/programs/compressed-token/program/src/close_token_account/processor.rs b/programs/compressed-token/program/src/close_token_account/processor.rs index 39e8e26b26..829b045686 100644 --- a/programs/compressed-token/program/src/close_token_account/processor.rs +++ b/programs/compressed-token/program/src/close_token_account/processor.rs @@ -4,9 +4,9 @@ use light_account_checks::{checks::check_signer, AccountInfoTrait}; use light_compressible::rent::{get_rent_exemption_lamports, AccountRentState}; use light_ctoken_types::state::{CToken, ZCompressedTokenMut, ZExtensionStructMut}; use light_program_profiler::profile; -use pinocchio::account_info::AccountInfo; #[cfg(target_os = "solana")] use pinocchio::sysvars::Sysvar; +use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; use spl_pod::solana_msg::msg; use spl_token_2022::state::AccountState; @@ -38,8 +38,9 @@ pub fn process_close_token_account( pub fn validate_token_account_close_instruction( accounts: &CloseTokenAccountAccounts, ctoken: &ZCompressedTokenMut<'_>, -) -> Result<(bool, bool), ProgramError> { - validate_token_account::(accounts, ctoken) +) -> Result<(), ProgramError> { + validate_token_account::(accounts, ctoken)?; + Ok(()) } /// Validates that a ctoken solana account is ready to be closed. @@ -48,7 +49,7 @@ pub fn validate_token_account_close_instruction( pub fn validate_token_account_for_close_transfer2( accounts: &CloseTokenAccountAccounts, ctoken: &ZCompressedTokenMut<'_>, -) -> Result<(bool, bool), ProgramError> { +) -> Result { validate_token_account::(accounts, ctoken) } @@ -56,7 +57,7 @@ pub fn validate_token_account_for_close_transfer2( fn validate_token_account( accounts: &CloseTokenAccountAccounts, ctoken: &ZCompressedTokenMut<'_>, -) -> Result<(bool, bool), ProgramError> { +) -> Result { if accounts.token_account.key() == accounts.destination.key() { return Err(ProgramError::InvalidAccountData); } @@ -74,10 +75,9 @@ fn validate_token_account( return Err(ErrorCode::NonNativeHasBalance.into()); } } - // Verify the authority matches the account owner or rent authority (if compressible) - let owner_matches = ctoken.owner.to_bytes() == *accounts.authority.key(); + // For COMPRESS_AND_CLOSE: Only compressible accounts (with Compressible extension) are allowed + // For regular close: Owner must match if let Some(extensions) = ctoken.extensions.as_ref() { - // Look for compressible extension for extension in extensions { if let ZExtensionStructMut::Compressible(compressible_ext) = extension { let rent_sponsor = accounts @@ -89,54 +89,56 @@ fn validate_token_account( } if COMPRESS_AND_CLOSE { - #[allow(clippy::collapsible_if)] - if !owner_matches { - if compressible_ext.compression_authority != *accounts.authority.key() { - msg!("rent authority mismatch"); - return Err(ProgramError::InvalidAccountData); - } + // For CompressAndClose: ONLY compression_authority can compress and close + if compressible_ext.compression_authority != *accounts.authority.key() { + msg!("compress and close requires compression authority"); + return Err(ProgramError::InvalidAccountData); + } + + #[cfg(target_os = "solana")] + let current_slot = pinocchio::sysvars::clock::Clock::get() + .map_err(convert_program_error)? + .slot; + + #[cfg(target_os = "solana")] + { + let is_compressible = compressible_ext + .is_compressible( + accounts.token_account.data_len() as u64, + current_slot, + accounts.token_account.lamports(), + ) + .map_err(|_| ProgramError::InvalidAccountData)?; - #[cfg(target_os = "solana")] - use pinocchio::sysvars::Sysvar; - #[cfg(target_os = "solana")] - let current_slot = pinocchio::sysvars::clock::Clock::get() - .map_err(convert_program_error)? - .slot; - - // For rent authority, check timing constraints - #[cfg(target_os = "solana")] - { - let is_compressible = compressible_ext - .is_compressible( - accounts.token_account.data_len() as u64, - current_slot, - accounts.token_account.lamports(), - ) - .map_err(|_| ProgramError::InvalidAccountData)?; - - if is_compressible.is_none() { - msg!("account not compressible"); - return Err(ProgramError::InvalidAccountData); - } else { - return Ok((true, compressible_ext.compress_to_pubkey())); - } + if is_compressible.is_none() { + msg!("account not compressible"); + return Err(ProgramError::InvalidAccountData); } } + + return Ok(compressible_ext.compress_to_pubkey()); } - // Check if authority is the rent authority && rent_sponsor is the destination account + // For regular close (!COMPRESS_AND_CLOSE): fall through to owner check } } } - if !owner_matches { + + // CompressAndClose requires Compressible extension - if we reach here without returning, reject + if COMPRESS_AND_CLOSE { + msg!("compress and close requires compressible extension"); + return Err(ProgramError::InvalidAccountData); + } + + // For regular close: verify authority matches owner + if !pubkey_eq(ctoken.owner.array_ref(), accounts.authority.key()) { msg!( - "owner: ctoken.owner {:?} != {:?} authority", + "owner mismatch: ctoken.owner {:?} != {:?} authority", solana_pubkey::Pubkey::from(ctoken.owner.to_bytes()), solana_pubkey::Pubkey::from(*accounts.authority.key()) ); - // If we have no rent authority owner must match return Err(ErrorCode::OwnerMismatch.into()); } - Ok((false, false)) + Ok(false) } pub fn close_token_account(accounts: &CloseTokenAccountAccounts<'_>) -> Result<(), ProgramError> { diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs index 4fdcc69140..f7e52b6086 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs @@ -7,7 +7,10 @@ use light_ctoken_types::{ state::{ZCompressedTokenMut, ZExtensionStructMut}, }; use light_program_profiler::profile; -use pinocchio::{account_info::AccountInfo, pubkey::Pubkey}; +use pinocchio::{ + account_info::AccountInfo, + pubkey::{pubkey_eq, Pubkey}, +}; use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; @@ -37,31 +40,29 @@ pub fn process_compress_and_close( let close_inputs = compress_and_close_inputs.ok_or(ErrorCode::CompressAndCloseDestinationMissing)?; - let (compression_authority_is_signer, compress_to_pubkey) = - validate_token_account_for_close_transfer2( - &CloseTokenAccountAccounts { - token_account: token_account_info, - destination: close_inputs.destination, - authority, - rent_sponsor: Some(close_inputs.rent_sponsor), - }, - ctoken, - )?; + // Validate token account - only compressible accounts with compression_authority are allowed + let compress_to_pubkey = validate_token_account_for_close_transfer2( + &CloseTokenAccountAccounts { + token_account: token_account_info, + destination: close_inputs.destination, + authority, + rent_sponsor: Some(close_inputs.rent_sponsor), + }, + ctoken, + )?; - if compression_authority_is_signer { - // Compress the complete balance to this compressed token account. - let compressed_account = close_inputs - .compressed_token_account - .ok_or(ErrorCode::CompressAndCloseOutputMissing)?; - validate_compressed_token_account( - packed_accounts, - amount, - compressed_account, - ctoken, - compress_to_pubkey, - token_account_info.key(), - )?; - } + // Validate compressed output matches the account being closed + let compressed_account = close_inputs + .compressed_token_account + .ok_or(ErrorCode::CompressAndCloseOutputMissing)?; + validate_compressed_token_account( + packed_accounts, + amount, + compressed_account, + ctoken, + compress_to_pubkey, + token_account_info.key(), + )?; *ctoken.amount = 0.into(); Ok(()) @@ -83,6 +84,16 @@ fn validate_compressed_token_account( return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); } + if !pubkey_eq( + ctoken.mint.array_ref(), + packed_accounts + .get_u8(compressed_token_account.mint, "CompressAndClose: mint")? + .key(), + ) { + msg!("Invalid mint PDA derivation"); + return Err(ErrorCode::MintActionInvalidMintPda.into()); + } + // Owners should match if not compressing to pubkey if compress_to_pubkey { // Owner should match token account pubkey if compressing to pubkey diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index ddeabda896..8ce220d1b6 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -9,6 +9,7 @@ use light_ctoken_types::{ use light_program_profiler::profile; use pinocchio::{ account_info::AccountInfo, + pubkey::pubkey_eq, sysvars::{clock::Clock, Sysvar}, }; use spl_pod::solana_msg::msg; @@ -39,7 +40,7 @@ pub fn compress_or_decompress_ctokens( let (mut ctoken, _) = CToken::zero_copy_at_mut_checked(&mut token_account_data)?; - if ctoken.mint.to_bytes() != mint { + if !pubkey_eq(ctoken.mint.array_ref(), &mint) { msg!( "mint mismatch account: ctoken.mint {:?}, mint {:?}", solana_pubkey::Pubkey::new_from_array(ctoken.mint.to_bytes()), diff --git a/sdk-tests/sdk-token-test/src/lib.rs b/sdk-tests/sdk-token-test/src/lib.rs index f880c1f1f7..e247810e5a 100644 --- a/sdk-tests/sdk-token-test/src/lib.rs +++ b/sdk-tests/sdk-token-test/src/lib.rs @@ -12,9 +12,6 @@ mod ctoken_pda; pub mod mint_compressed_tokens_cpi_write; mod pda_ctoken; mod process_batch_compress_tokens; -mod process_compress_and_close_cpi; -mod process_compress_and_close_cpi_context; -mod process_compress_and_close_cpi_indices; mod process_compress_full_and_close; mod process_compress_tokens; mod process_create_compressed_account; @@ -31,9 +28,6 @@ use light_sdk::instruction::account_meta::CompressedAccountMeta; use light_sdk_types::cpi_accounts::{v2::CpiAccounts, CpiAccountsConfig}; pub use pda_ctoken::*; use process_batch_compress_tokens::process_batch_compress_tokens; -use process_compress_and_close_cpi::process_compress_and_close_cpi; -use process_compress_and_close_cpi_context::process_compress_and_close_cpi_context; -use process_compress_and_close_cpi_indices::process_compress_and_close_cpi_indices; use process_compress_full_and_close::process_compress_full_and_close; use process_compress_tokens::process_compress_tokens; use process_create_compressed_account::process_create_compressed_account; @@ -125,42 +119,6 @@ pub mod sdk_token_test { ) } - /// Process compress_and_close using the new CompressAndClose mode - /// Compress and close using the higher-level SDK function - /// This uses compress_and_close_ctoken_accounts which handles all index discovery - pub fn compress_and_close_cpi<'info>( - ctx: Context<'_, '_, '_, 'info, OneCTokenAccount<'info>>, - with_compression_authority: bool, - system_accounts_offset: u8, - ) -> Result<()> { - process_compress_and_close_cpi(ctx, with_compression_authority, system_accounts_offset) - } - - /// Process compress_and_close using the new CompressAndClose mode - /// Compress and close using the higher-level SDK function - /// This uses compress_and_close_ctoken_accounts which handles all index discovery - pub fn compress_and_close_cpi_with_cpi_context<'info>( - ctx: Context<'_, '_, 'info, 'info, Generic<'info>>, - indices: Vec< - light_compressed_token_sdk::compressed_token::compress_and_close::CompressAndCloseIndices, - >, - params: MintCompressedTokensCpiWriteParams, - ) -> Result<()> { - process_compress_and_close_cpi_context(ctx, indices, params) - } - - /// Compress and close with manual indices - /// This atomically compresses tokens and closes the account in a single instruction - pub fn compress_and_close_cpi_indices<'info>( - ctx: Context<'_, '_, 'info, 'info, Generic<'info>>, - indices: Vec< - light_compressed_token_sdk::compressed_token::compress_and_close::CompressAndCloseIndices, - >, - system_accounts_offset: u8, - ) -> Result<()> { - process_compress_and_close_cpi_indices(ctx, indices, system_accounts_offset) - } - /// Decompress full balance from compressed accounts with CPI context /// This decompresses the entire balance to destination ctoken accounts pub fn decompress_full_cpi<'info>( diff --git a/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi.rs b/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi.rs deleted file mode 100644 index 2848567627..0000000000 --- a/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi.rs +++ /dev/null @@ -1,54 +0,0 @@ -use anchor_lang::{prelude::*, solana_program::program::invoke}; -use light_compressed_token_sdk::compressed_token::compress_and_close::compress_and_close_ctoken_accounts; -use light_sdk_types::cpi_accounts::{v2::CpiAccounts as CpiAccountsSmall, CpiAccountsConfig}; - -use crate::OneCTokenAccount; - -/// Process compress_and_close operation using the higher-level compress_and_close_ctoken_accounts function -/// This demonstrates using the SDK's abstraction for compress and close operations -pub fn process_compress_and_close_cpi<'info>( - ctx: Context<'_, '_, '_, 'info, OneCTokenAccount<'info>>, - with_compression_authority: bool, - system_accounts_offset: u8, -) -> Result<()> { - // Parse CPI accounts following the established pattern - let config = CpiAccountsConfig::new(crate::LIGHT_CPI_SIGNER); - let (_token_account_infos, system_account_infos) = ctx - .remaining_accounts - .split_at(system_accounts_offset as usize); - - let cpi_accounts = CpiAccountsSmall::new_with_config( - ctx.accounts.signer.as_ref(), - system_account_infos, - config, - ); - // Use the higher-level compress_and_close_ctoken_accounts function - // This function handles: - // - Deserializing the compressed token accounts - // - Extracting rent authority from extensions if needed - // - Finding all required indices - // - Building the compress_and_close instruction - let instruction = compress_and_close_ctoken_accounts( - *ctx.accounts.signer.key, // fee_payer - with_compression_authority, // whether to use rent authority from extension - ctx.accounts.output_queue.to_account_info(), // output queue where compressed accounts will be stored - &[&ctx.accounts.ctoken_account.to_account_info()], // slice of ctoken account infos - cpi_accounts.tree_accounts().unwrap(), // packed accounts for the instruction - ) - .map_err(|_| ProgramError::InvalidInstructionData)?; - - // Build the account infos for the CPI call - let account_infos = [ - &[ - cpi_accounts.fee_payer().clone(), - ctx.accounts.output_queue.to_account_info(), - ][..], - ctx.remaining_accounts, - ] - .concat(); - - // Execute the instruction - invoke(&instruction, account_infos.as_slice())?; - - Ok(()) -} diff --git a/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi_context.rs b/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi_context.rs deleted file mode 100644 index 31d5bd456e..0000000000 --- a/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi_context.rs +++ /dev/null @@ -1,52 +0,0 @@ -use anchor_lang::{prelude::*, solana_program::program::invoke}; -use light_compressed_token_sdk::compressed_token::{ - compress_and_close::{ - compress_and_close_ctoken_accounts_with_indices, CompressAndCloseIndices, - }, - transfer2::Transfer2CpiAccounts, -}; - -use crate::{ - mint_compressed_tokens_cpi_write::{ - process_mint_compressed_tokens_cpi_write, MintCompressedTokensCpiWriteParams, - }, - Generic, -}; - -/// Process compress_and_close operation using the new CompressAndClose mode with manual indices -/// This combines token compression and account closure in a single atomic transaction -pub fn process_compress_and_close_cpi_context<'info>( - ctx: Context<'_, '_, '_, 'info, Generic<'info>>, - indices: Vec, - params: MintCompressedTokensCpiWriteParams, -) -> Result<()> { - // Now use Transfer2CpiAccounts for compress_and_close - let transfer2_accounts = Transfer2CpiAccounts::try_from_account_infos_cpi_context( - ctx.accounts.signer.as_ref(), - ctx.remaining_accounts, - ) - .map_err(|_| ProgramError::InvalidAccountData)?; - - process_mint_compressed_tokens_cpi_write(&ctx, params, &transfer2_accounts)?; - - // Get the packed accounts from Transfer2CpiAccounts - let packed_accounts = transfer2_accounts.packed_accounts(); - - // Use the SDK's compress_and_close function with the provided indices - let instruction = compress_and_close_ctoken_accounts_with_indices( - *ctx.accounts.signer.key, - false, - transfer2_accounts.cpi_context.map(|c| c.key()), // Use the CPI context from Transfer2CpiAccounts - &indices, - packed_accounts, // Pass complete packed accounts - ) - .map_err(ProgramError::from)?; - - // Use Transfer2CpiAccounts to build account infos for invoke - invoke( - &instruction, - transfer2_accounts.to_account_infos().as_slice(), - )?; - - Ok(()) -} diff --git a/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi_indices.rs b/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi_indices.rs deleted file mode 100644 index 72ddfbc1c3..0000000000 --- a/sdk-tests/sdk-token-test/src/process_compress_and_close_cpi_indices.rs +++ /dev/null @@ -1,44 +0,0 @@ -use anchor_lang::{prelude::*, solana_program::program::invoke}; -use light_compressed_token_sdk::compressed_token::{ - compress_and_close::{ - compress_and_close_ctoken_accounts_with_indices, CompressAndCloseIndices, - }, - transfer2::Transfer2CpiAccounts, -}; - -use crate::Generic; - -/// Process compress_and_close operation using the new CompressAndClose mode with manual indices -/// This combines token compression and account closure in a single atomic transaction -pub fn process_compress_and_close_cpi_indices<'info>( - ctx: Context<'_, '_, 'info, 'info, Generic<'info>>, - indices: Vec, - _system_accounts_offset: u8, -) -> Result<()> { - let fee_payer = ctx.accounts.signer.to_account_info(); - // Use the new Transfer2CpiAccounts to parse accounts - let transfer2_accounts = - Transfer2CpiAccounts::try_from_account_infos(&fee_payer, ctx.remaining_accounts) - .map_err(|_| ProgramError::InvalidAccountData)?; - msg!("transfer2_accounts {:?}", transfer2_accounts); - // Get the packed accounts from the parsed structure - let packed_accounts = transfer2_accounts.packed_accounts(); - - // Use the SDK's compress_and_close function with the provided indices - // Use the signer from ctx.accounts as fee_payer since it's passed separately in the test - let instruction = compress_and_close_ctoken_accounts_with_indices( - *ctx.accounts.signer.key, - false, - None, // cpi_context_pubkey - &indices, - packed_accounts, - ) - .map_err(ProgramError::from)?; - - invoke( - &instruction, - transfer2_accounts.to_account_infos().as_slice(), - )?; - - Ok(()) -} diff --git a/sdk-tests/sdk-token-test/tests/compress_and_close_cpi.rs b/sdk-tests/sdk-token-test/tests/compress_and_close_cpi.rs deleted file mode 100644 index 8663df1d4a..0000000000 --- a/sdk-tests/sdk-token-test/tests/compress_and_close_cpi.rs +++ /dev/null @@ -1,651 +0,0 @@ -//#![cfg(feature = "test-sbf")] - -use anchor_lang::InstructionData; -use light_compressed_token_sdk::compressed_token::{ - compress_and_close::{pack_for_compress_and_close, CompressAndCloseAccounts}, - create_compressed_mint::find_spl_mint_address, -}; -use light_ctoken_types::instructions::mint_action::Recipient; -use light_program_test::{Indexer, LightProgramTest, ProgramTestConfig, Rpc}; -use light_sdk::instruction::PackedAccounts; -use light_test_utils::{airdrop_lamports, assert_transfer2::assert_transfer2_compress_and_close}; -use light_token_client::{ - actions::mint_action_comprehensive, - instructions::{mint_action::NewMint, transfer2::CompressAndCloseInput}, -}; -use solana_sdk::{ - instruction::{AccountMeta, Instruction}, - pubkey::Pubkey, - signature::Keypair, - signer::Signer, - transaction::Transaction, -}; - -/// Test context containing all the common test data -struct TestContext { - payer: Keypair, - owners: Vec, - mint_seed: Keypair, - mint_pubkey: Pubkey, - token_account_pubkeys: Vec, - mint_amount: u64, - with_compressible_extension: bool, -} - -/// Shared setup function for compress_and_close tests -async fn setup_compress_and_close_test( - num_ctoken_accounts: usize, - with_compressible_extension: bool, -) -> (LightProgramTest, TestContext) { - let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2( - false, - Some(vec![("sdk_token_test", sdk_token_test::ID)]), - )) - .await - .unwrap(); - - let payer = rpc.get_payer().insecure_clone(); - - // Create compressed mint - let mint_seed = Keypair::new(); - let mint_pubkey = find_spl_mint_address(&mint_seed.pubkey()).0; - let mint_authority = payer.pubkey(); - let decimals = 9u8; - - // Create owners - one for each token account - let mut owners = Vec::with_capacity(num_ctoken_accounts); - for _ in 0..num_ctoken_accounts { - let owner = Keypair::new(); - // Fund each owner - airdrop_lamports(&mut rpc, &owner.pubkey(), 10_000_000_000) - .await - .unwrap(); - owners.push(owner); - } - - // Set up rent authority using the first owner - let rent_sponsor = if with_compressible_extension { - rpc.test_accounts.funding_pool_config.rent_sponsor_pda - } else { - // Use first owner as both rent authority and recipient - owners[0].pubkey() - }; - let pre_pay_num_epochs = 0; - // Create ATA accounts for each owner - let mut token_account_pubkeys = Vec::with_capacity(num_ctoken_accounts); - - use light_compressed_token_sdk::ctoken::{ - derive_ctoken_ata, CompressibleParams, CreateAssociatedTokenAccount, - }; - - for owner in &owners { - let (token_account_pubkey, _) = derive_ctoken_ata(&owner.pubkey(), &mint_pubkey); - - // Create the ATA account with compressible extension if needed - let compressible_params = if with_compressible_extension { - CompressibleParams { - compressible_config: rpc - .test_accounts - .funding_pool_config - .compressible_config_pda, - rent_sponsor, - pre_pay_num_epochs, - lamports_per_write: None, - compress_to_account_pubkey: None, - token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, - } - } else { - CompressibleParams::default() - }; - - let create_token_account_ix = CreateAssociatedTokenAccount::new( - payer.pubkey(), - owner.pubkey(), - mint_pubkey, - compressible_params, - ) - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[create_token_account_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - token_account_pubkeys.push(token_account_pubkey); - } - - // Now create mint and mint to the decompressed token accounts - let mint_amount = 1000; - - let decompressed_recipients = owners - .iter() - .map(|owner| Recipient::new(owner.pubkey(), mint_amount)) - .collect::>(); - println!("decompressed_recipients {:?}", decompressed_recipients); - // Create the mint and mint to the existing ATAs - mint_action_comprehensive( - &mut rpc, - &mint_seed, - &payer, - &payer, - Vec::new(), // No compressed recipients - decompressed_recipients, // Mint to owners - ATAs already exist - None, - None, - Some(NewMint { - decimals, - mint_authority, - supply: 0, - freeze_authority: None, - metadata: None, - version: 3, - }), - ) - .await - .unwrap(); - - ( - rpc, - TestContext { - payer, - owners, - mint_seed, - mint_pubkey, - token_account_pubkeys, - mint_amount, - with_compressible_extension, - }, - ) -} - -#[tokio::test] -async fn test_compress_and_close_cpi_indices_owner() { - let (mut rpc, ctx) = setup_compress_and_close_test(1, true).await; - let payer_pubkey = ctx.payer.pubkey(); - let token_account_pubkey = ctx.token_account_pubkeys[0]; - - // Prepare accounts for CPI instruction - let mut remaining_accounts = PackedAccounts::default(); - - // Get output tree for compression - let output_tree_info = rpc.get_random_state_tree_info().unwrap(); - - // Get the ctoken account data - let ctoken_solana_account = rpc - .get_account(token_account_pubkey) - .await - .unwrap() - .unwrap(); - // Add output queue first so it's at index 0 - remaining_accounts.insert_or_get(output_tree_info.queue); - // Use pack_for_compress_and_close to pack all required accounts - let indices = pack_for_compress_and_close( - token_account_pubkey, - ctoken_solana_account.data.as_slice(), - &mut remaining_accounts, - false, - ) - .unwrap(); - - // Add light system program accounts - let config = CompressAndCloseAccounts::default(); - remaining_accounts - .add_custom_system_accounts(config) - .unwrap(); - - let (account_metas, system_accounts_start_offset, _) = remaining_accounts.to_account_metas(); - - // Create the compress_and_close_cpi_indices instruction data - let indices_vec = vec![indices]; - - let instruction_data = sdk_token_test::instruction::CompressAndCloseCpiIndices { - indices: indices_vec, - system_accounts_offset: system_accounts_start_offset as u8, - }; - - // Create the instruction - let instruction = Instruction { - program_id: sdk_token_test::ID, - accounts: [vec![AccountMeta::new(payer_pubkey, true)], account_metas].concat(), - data: instruction_data.data(), - }; - - // Sign with payer and compression_authority (which is owner when no extension) - let signers = vec![&ctx.payer, &ctx.owners[0]]; - - rpc.create_and_send_transaction(&[instruction], &payer_pubkey, &signers) - .await - .unwrap(); - - let compress_and_close_input = CompressAndCloseInput { - solana_ctoken_account: token_account_pubkey, - authority: ctx.owners[0].pubkey(), // Owner is the authority in this test - output_queue: output_tree_info.queue, - destination: None, // Owner is the authority and destination in this test - is_compressible: false, - }; - - assert_transfer2_compress_and_close(&mut rpc, compress_and_close_input).await; - - println!("✅ CompressAndClose CPI test passed!"); -} -/// Test the high-level compress_and_close_cpi function -/// This test uses the SDK's compress_and_close_ctoken_accounts which handles all index discovery -#[tokio::test] -async fn test_compress_and_close_cpi_high_level() { - let (mut rpc, ctx) = setup_compress_and_close_test(1, false).await; - let payer_pubkey = ctx.payer.pubkey(); - let token_account_pubkey = ctx.token_account_pubkeys[0]; - - // Prepare accounts for CPI instruction - using high-level function - // Mirror the exact setup from test_compress_and_close_cpi_indices - let mut remaining_accounts = PackedAccounts::default(); - - // Get output tree for compression - let output_tree_info = rpc.get_random_state_tree_info().unwrap(); - remaining_accounts.insert_or_get(output_tree_info.queue); - // DON'T pack the output tree - it's passed separately as output_queue account - let ctoken_solana_account = rpc - .get_account(token_account_pubkey) - .await - .unwrap() - .unwrap(); - - pack_for_compress_and_close( - token_account_pubkey, - ctoken_solana_account.data.as_slice(), - &mut remaining_accounts, - ctx.with_compressible_extension, // false - using owner as authority - ) - .unwrap(); - - let config = CompressAndCloseAccounts::default(); - remaining_accounts - .add_custom_system_accounts(config) - .unwrap(); - // Add accounts to instruction - let (account_metas, system_accounts_start_offset, _) = remaining_accounts.to_account_metas(); - - // Create the compress_and_close_cpi instruction data for high-level function - let instruction_data = sdk_token_test::instruction::CompressAndCloseCpi { - with_compression_authority: false, // Don't use rent authority from extension - system_accounts_offset: system_accounts_start_offset as u8, // No accounts before system accounts in remaining_accounts - }; - - // Create the instruction - OneCTokenAccount expects [signer, ctoken_account, ...remaining] - let instruction = Instruction { - program_id: sdk_token_test::ID, - accounts: [ - vec![ - AccountMeta::new(payer_pubkey, true), // signer (mutable) - AccountMeta::new(token_account_pubkey, false), // ctoken_account (mutable) - AccountMeta::new(output_tree_info.queue, false), // ctoken_account (mutable) - ], - account_metas, // remaining accounts (trees, mint, owner, etc.) - ] - .concat(), - data: instruction_data.data(), - }; - - // Execute transaction - sign with payer and owner - let transaction = Transaction::new_signed_with_payer( - &[instruction], - Some(&payer_pubkey), - &[&ctx.payer, &ctx.owners[0]], - rpc.get_latest_blockhash().await.unwrap().0, - ); - - // Check if there are any compressed accounts BEFORE compress_and_close - let pre_compress_accounts = rpc - .get_compressed_token_accounts_by_owner(&ctx.owners[0].pubkey(), None, None) - .await - .unwrap() - .value - .items; - println!( - "Compressed accounts BEFORE compress_and_close: {}", - pre_compress_accounts.len() - ); - for (i, acc) in pre_compress_accounts.iter().enumerate() { - println!( - " Pre-compress Account {}: amount={}, mint={}", - i, acc.token.amount, acc.token.mint - ); - } - - rpc.process_transaction(transaction).await.unwrap(); - - // Verify compressed account was created for the first owner - let compressed_accounts = rpc - .get_compressed_token_accounts_by_owner(&ctx.owners[0].pubkey(), None, None) - .await - .unwrap() - .value - .items; - - println!("Compressed accounts found: {:?}", compressed_accounts); - assert_eq!(compressed_accounts[0].token.amount, ctx.mint_amount); - assert_eq!(compressed_accounts[0].token.mint, ctx.mint_pubkey); - assert_eq!(compressed_accounts.len(), 1); - - // Verify source account is closed - let closed_account = rpc.get_account(token_account_pubkey).await.unwrap(); - if let Some(acc) = closed_account { - assert_eq!( - acc.lamports, 0, - "Account should have 0 lamports after closing" - ); - } - - println!("✅ CompressAndClose CPI high-level test passed!"); -} - -/// Test compressing 4 token accounts in a single instruction -/// This test uses compress_and_close_cpi_indices which supports multiple accounts -#[tokio::test] -async fn test_compress_and_close_cpi_multiple() { - let (mut rpc, ctx) = setup_compress_and_close_test(4, false).await; - let payer_pubkey = ctx.payer.pubkey(); - - // Prepare accounts for CPI instruction - let mut remaining_accounts = PackedAccounts::default(); - - // Get output tree for compression - let output_tree_info = rpc.get_random_state_tree_info().unwrap(); - remaining_accounts.insert_or_get(output_tree_info.queue); - - // Collect indices for all 4 accounts - let mut indices_vec = Vec::with_capacity(ctx.token_account_pubkeys.len()); - - for token_account_pubkey in ctx.token_account_pubkeys.iter() { - let ctoken_solana_account = rpc - .get_account(*token_account_pubkey) - .await - .unwrap() - .unwrap(); - println!("packing token_account_pubkey {:?}", token_account_pubkey); - let indices = pack_for_compress_and_close( - *token_account_pubkey, - ctoken_solana_account.data.as_slice(), - &mut remaining_accounts, - ctx.with_compressible_extension, - ) - .unwrap(); - indices_vec.push(indices); - } - - // Add light system program accounts - let config = CompressAndCloseAccounts::default(); - remaining_accounts - .add_custom_system_accounts(config) - .unwrap(); - - let (account_metas, system_accounts_start_offset, _) = remaining_accounts.to_account_metas(); - - println!("Total account_metas: {}", account_metas.len()); - for (i, meta) in account_metas.iter().enumerate() { - println!( - " [{}] {:?} (signer: {}, writable: {})", - i, meta.pubkey, meta.is_signer, meta.is_writable - ); - } - println!( - "System accounts start offset: {}", - system_accounts_start_offset - ); - println!("indices_vec {:?}", indices_vec); - println!( - "owners {:?}", - ctx.owners.iter().map(|x| x.pubkey()).collect::>() - ); - // Create the compress_and_close_cpi_indices instruction data - let instruction_data = sdk_token_test::instruction::CompressAndCloseCpiIndices { - indices: indices_vec, - system_accounts_offset: system_accounts_start_offset as u8, - }; - - // Create the instruction - let instruction = Instruction { - program_id: sdk_token_test::ID, - accounts: [vec![AccountMeta::new(payer_pubkey, true)], account_metas].concat(), - data: instruction_data.data(), - }; - - // Execute transaction with all 4 accounts compressed in a single instruction - // Need to sign with all owners since we're compressing their accounts - let mut signers = vec![&ctx.payer]; - for owner in &ctx.owners { - signers.push(owner); - } - - rpc.create_and_send_transaction(&[instruction], &payer_pubkey, &signers) - .await - .unwrap(); - - // Verify compressed accounts were created - one for each owner - let mut total_compressed_accounts = 0; - for owner in &ctx.owners { - let compressed_accounts = rpc - .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) - .await - .unwrap() - .value - .items; - - assert_eq!(compressed_accounts.len(), 1); - assert_eq!(compressed_accounts[0].token.amount, ctx.mint_amount); - assert_eq!(compressed_accounts[0].token.mint, ctx.mint_pubkey); - total_compressed_accounts += compressed_accounts.len(); - } - assert_eq!(total_compressed_accounts, 4); - - // Verify all source accounts are closed - for token_account_pubkey in &ctx.token_account_pubkeys { - let closed_account = rpc.get_account(*token_account_pubkey).await.unwrap(); - if let Some(acc) = closed_account { - assert_eq!( - acc.lamports, 0, - "Account should have 0 lamports after closing" - ); - } - } - - println!("✅ CompressAndClose CPI multiple accounts test passed!"); -} - -/// Test compress_and_close with CPI context for optimized multi-program transactions -/// This test uses CPI context to cache signer checks for potential cross-program operations -#[tokio::test] -async fn test_compress_and_close_cpi_with_context() { - let (mut rpc, ctx) = setup_compress_and_close_test(1, false).await; - let payer_pubkey = ctx.payer.pubkey(); - let token_account_pubkey = ctx.token_account_pubkeys[0]; - - // Import required types for minting - use anchor_lang::AnchorDeserialize; - use light_ctoken_types::instructions::mint_action::CompressedMintWithContext; - use sdk_token_test::mint_compressed_tokens_cpi_write::MintCompressedTokensCpiWriteParams; - - // Get initial rent recipient balance (owner in this case since no extension) - let initial_recipient_balance = rpc - .get_account(ctx.owners[0].pubkey()) - .await - .unwrap() - .map(|acc| acc.lamports) - .unwrap_or(0); - - // Prepare accounts for CPI instruction with CPI context - let mut remaining_accounts = PackedAccounts::default(); - // Derive compressed mint address using utility function - let address_tree_info = rpc.get_address_tree_v2(); - let compressed_mint_address = - light_compressed_token_sdk::compressed_token::create_compressed_mint::derive_compressed_mint_address( - &ctx.mint_seed.pubkey(), - &address_tree_info.tree, - ); - - // Get the compressed mint account - let compressed_mint_account = rpc - .get_compressed_account(compressed_mint_address, None) - .await - .unwrap() - .value - .ok_or("Compressed mint account not found") - .unwrap(); - - let cpi_context_pubkey = compressed_mint_account - .tree_info - .cpi_context - .expect("CPI context required for this test"); - // Add light system program accounts (following the pattern from other tests) - use light_compressed_token_sdk::compressed_token::compress_and_close::CompressAndCloseAccounts; - let config = CompressAndCloseAccounts::new_with_cpi_context(Some(cpi_context_pubkey), None); - remaining_accounts - .add_custom_system_accounts(config) - .unwrap(); - - // Create mint params to populate CPI context - let mint_recipients = vec![Recipient::new(ctx.owners[0].pubkey(), 500)]; - - // Deserialize the mint data - use light_ctoken_types::state::CompressedMint; - let compressed_mint = - CompressedMint::deserialize(&mut compressed_mint_account.data.unwrap().data.as_slice()) - .unwrap(); - remaining_accounts.insert_or_get(compressed_mint_account.tree_info.queue); - - // Create CompressedMintWithContext for minting to populate CPI context - let compressed_mint_with_context = CompressedMintWithContext { - prove_by_index: true, - leaf_index: compressed_mint_account.leaf_index, - root_index: 0, - address: compressed_mint_address, - mint: compressed_mint.try_into().unwrap(), - }; - let mint_params = MintCompressedTokensCpiWriteParams { - compressed_mint_with_context, - recipients: mint_recipients, - cpi_context: light_ctoken_types::instructions::mint_action::CpiContext { - set_context: false, - first_set_context: true, // First operation sets the context - in_tree_index: remaining_accounts.insert_or_get(compressed_mint_account.tree_info.tree), - in_queue_index: remaining_accounts - .insert_or_get(compressed_mint_account.tree_info.queue), - out_queue_index: remaining_accounts - .insert_or_get(compressed_mint_account.tree_info.queue), - token_out_queue_index: remaining_accounts - .insert_or_get(compressed_mint_account.tree_info.queue), - assigned_account_index: 0, - ..Default::default() - }, - cpi_context_pubkey, - }; - // Get the ctoken account data - let ctoken_solana_account = rpc - .get_account(token_account_pubkey) - .await - .unwrap() - .unwrap(); - - // Debug: Check the actual token account balance - use light_ctoken_types::state::CToken; - use light_zero_copy::traits::ZeroCopyAt; - let (ctoken_account, _) = CToken::zero_copy_at(ctoken_solana_account.data.as_slice()).unwrap(); - println!( - "DEBUG: Token account balance before compress_and_close: {}", - ctoken_account.amount - ); - println!("DEBUG: Expected balance: {}", ctx.mint_amount); - - // Generate indices for compress and close operation (following the pattern from test_compress_and_close_cpi_indices) - let indices = pack_for_compress_and_close( - token_account_pubkey, - ctoken_solana_account.data.as_slice(), - &mut remaining_accounts, - ctx.with_compressible_extension, // false - using owner as authority - ) - .unwrap(); - - let (account_metas, system_accounts_start_offset, _) = remaining_accounts.to_account_metas(); - - println!("CPI Context test:"); - println!(" CPI context account: {:?}", cpi_context_pubkey); - println!(" Token account: {:?}", token_account_pubkey); - println!( - " Output queue: {:?}", - compressed_mint_account.tree_info.queue - ); - println!( - " System accounts start offset: {}", - system_accounts_start_offset - ); - println!("account_metas: {:?}", account_metas); - - // Create the compress_and_close_cpi_with_cpi_context instruction - let instruction_data = sdk_token_test::instruction::CompressAndCloseCpiWithCpiContext { - indices: vec![indices], // Use generated indices like CompressAndCloseCpiIndices pattern - params: mint_params, - }; - - // Create the instruction - TwoCTokenAccounts expects signer, ctoken_account1, ctoken_account2, output_queue - // But we're only using one account, so we'll pass the same account twice (second one won't be used) - let instruction = Instruction { - program_id: sdk_token_test::ID, - accounts: [ - vec![ - AccountMeta::new(payer_pubkey, true), // signer - ], - account_metas, // remaining accounts (trees, system accounts, etc.) - ] - .concat(), - data: instruction_data.data(), - }; - - // Execute transaction - sign with payer and owner - let transaction = Transaction::new_signed_with_payer( - &[instruction], - Some(&payer_pubkey), - &[&ctx.payer, &ctx.owners[0]], - rpc.get_latest_blockhash().await.unwrap().0, - ); - - rpc.process_transaction(transaction).await.unwrap(); - - // Verify compressed account was created - let compressed_accounts = rpc - .get_compressed_token_accounts_by_owner(&ctx.owners[0].pubkey(), None, None) - .await - .unwrap() - .value - .items; - - assert_eq!(compressed_accounts.len(), 2); - assert_eq!(compressed_accounts[0].token.amount, ctx.mint_amount); - assert_eq!(compressed_accounts[0].token.mint, ctx.mint_pubkey); - assert_eq!(compressed_accounts[1].token.amount, 500); - assert_eq!(compressed_accounts[1].token.mint, ctx.mint_pubkey); - - // Verify source account is closed - let closed_account = rpc.get_account(token_account_pubkey).await.unwrap(); - if let Some(acc) = closed_account { - assert_eq!( - acc.lamports, 0, - "Account should have 0 lamports after closing" - ); - } - - // Verify rent was transferred to owner (no extension, so owner gets rent) - let final_recipient_balance = rpc - .get_account(ctx.owners[0].pubkey()) - .await - .unwrap() - .map(|acc| acc.lamports) - .unwrap_or(0); - - assert!( - final_recipient_balance > initial_recipient_balance, - "Owner should receive rent when no extension is present" - ); - - println!("✅ CompressAndClose CPI with CPI context test passed!"); -}