diff --git a/Cargo.lock b/Cargo.lock index 2a86a3f4d2..23a2e05b42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3584,6 +3584,7 @@ dependencies = [ "anchor-compressed-token", "anchor-lang", "arrayvec", + "bitvec", "borsh 0.10.4", "lazy_static", "light-account-checks", @@ -3990,13 +3991,21 @@ dependencies = [ "account-compression", "aligned-sized", "anchor-lang", + "borsh 0.10.4", + "light-account-checks", "light-batched-merkle-tree", - "light-compressed-token-sdk", "light-compressible", + "light-ctoken-types", + "light-macros", "light-merkle-tree-metadata", + "light-program-profiler", "light-system-program-anchor", + "solana-account-info", + "solana-instruction", + "solana-pubkey 2.4.0", "solana-sdk", "solana-security-txt", + "spl-pod", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7a7089479f..8dd688f5a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -229,7 +229,7 @@ num-traits = "0.2.19" zerocopy = { version = "0.8.25" } base64 = "0.13" zeroize = "=1.3.0" - +bitvec = { version = "1.0.1", default-features = false } # HTTP client reqwest = "0.12" diff --git a/program-libs/bloom-filter/Cargo.toml b/program-libs/bloom-filter/Cargo.toml index 6ac286f73c..20e32d7d52 100644 --- a/program-libs/bloom-filter/Cargo.toml +++ b/program-libs/bloom-filter/Cargo.toml @@ -11,7 +11,7 @@ solana = ["dep:solana-program-error"] pinocchio = ["dep:pinocchio"] [dependencies] -bitvec = "1.0.1" +bitvec = { workspace = true } solana-nostd-keccak = "0.1.3" num-bigint = { workspace = true } solana-program-error = { workspace = true, optional = true } 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 c9fd938261..9c882ed62d 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 @@ -1,5 +1,7 @@ +use light_client::rpc::Rpc; use light_ctoken_types::state::ZExtensionStructMut; use light_zero_copy::traits::ZeroCopyAtMut; +use solana_sdk::signer::Signer; use super::shared::*; @@ -844,20 +846,3 @@ async fn test_compress_and_close_output_validation_errors() { light_program_test::utils::assert::assert_rpc_error(result, 0, 18036).unwrap(); } } - -// ============================================================================ -// Failure Tests - Compressibility and Missing Accounts -// ============================================================================ - -#[tokio::test] -#[serial] -async fn test_compress_and_close_compressibility_and_missing_accounts() { - // Note: These tests would require either: - // 1. Manual instruction building to omit required accounts - // 2. Trying to close before the account is compressible - // - // These would require manual instruction building or special setup: - // - Test 12: Rent authority tries to close before account is compressible - // - Test 13: No destination account provided (error 6087 - CompressAndCloseDestinationMissing) - // - Test 14: Rent authority closes but no compressed output exists -} diff --git a/program-tests/compressed-token-test/tests/ctoken/shared.rs b/program-tests/compressed-token-test/tests/ctoken/shared.rs index fa12d0a25b..0822e9bcf7 100644 --- a/program-tests/compressed-token-test/tests/ctoken/shared.rs +++ b/program-tests/compressed-token-test/tests/ctoken/shared.rs @@ -10,6 +10,7 @@ pub use light_program_test::{ forester::compress_and_close_forester, program_test::TestRpc, LightProgramTest, ProgramTestConfig, }; +use light_registry::compressible::compressed_token::CompressAndCloseIndices; pub use light_test_utils::{ assert_close_token_account::assert_close_token_account, assert_create_token_account::{ @@ -756,7 +757,6 @@ pub async fn compress_and_close_forester_with_invalid_output( use std::str::FromStr; use anchor_lang::{InstructionData, ToAccountMetas}; - use light_compressed_token_sdk::instructions::compress_and_close::CompressAndCloseIndices; use light_compressible::config::CompressibleConfig; use light_ctoken_types::state::{CToken, ZExtensionStruct}; use light_registry::{ @@ -842,9 +842,7 @@ pub async fn compress_and_close_forester_with_invalid_output( source_index, mint_index, owner_index, - authority_index, rent_sponsor_index, - destination_index, }; // Add system accounts @@ -869,13 +867,14 @@ pub async fn compress_and_close_forester_with_invalid_output( registered_forester_pda, compression_authority, compressible_config, - compressed_token_program: compressed_token_program_id, }; let mut accounts = compress_and_close_accounts.to_account_metas(Some(true)); accounts.extend(remaining_account_metas); let instruction = CompressAndClose { + authority_index, + destination_index, indices: vec![indices], }; let instruction_data = instruction.data(); diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index ea3970ac2c..8faf5ff123 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -422,6 +422,8 @@ pub enum ErrorCode { MintActionInvalidCpiContextForCreateMint, #[msg("Invalid address tree pubkey in CPI context")] MintActionInvalidCpiContextAddressTreePubkey, + #[msg("CompressAndClose: Cannot use the same compressed output account for multiple closures")] + CompressAndCloseDuplicateOutput, } impl From for ProgramError { diff --git a/programs/compressed-token/program/Cargo.toml b/programs/compressed-token/program/Cargo.toml index b0ab0753ea..01fa8407ba 100644 --- a/programs/compressed-token/program/Cargo.toml +++ b/programs/compressed-token/program/Cargo.toml @@ -67,6 +67,7 @@ light-array-map = { workspace = true } pinocchio-pubkey = { workspace = true } pinocchio-system = { workspace = true } pinocchio-token-program = { workspace = true } +bitvec = { workspace = true } [dev-dependencies] rand = { workspace = true } diff --git a/programs/compressed-token/program/docs/instructions/TRANSFER2.md b/programs/compressed-token/program/docs/instructions/TRANSFER2.md index 34576cadda..aa199077de 100644 --- a/programs/compressed-token/program/docs/instructions/TRANSFER2.md +++ b/programs/compressed-token/program/docs/instructions/TRANSFER2.md @@ -280,6 +280,7 @@ When compression processing occurs (in both Path A and Path B): - 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 - Calculate compressible extension top-up if present (returns Option) - **Transfer deduplication optimization:** - Collects all transfers into a 40-element array indexed by account @@ -318,6 +319,7 @@ When compression processing occurs (in both Path A and Path B): - `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::CompressAndCloseDuplicateOutput` (error code: 6420) - Cannot use the same compressed output account for multiple CompressAndClose operations (security protection against fund theft) - `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/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs index 165afe2599..78abf4ff8b 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 @@ -1,5 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; +use bitvec::prelude::*; use light_account_checks::{checks::check_signer, packed_accounts::ProgramPackedAccounts}; use light_ctoken_types::{ instructions::transfer2::{ZCompression, ZCompressionMode, ZMultiTokenTransferOutputData}, @@ -12,8 +13,7 @@ use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; use crate::{ close_token_account::{ - accounts::CloseTokenAccountAccounts, - processor::{close_token_account, validate_token_account_for_close_transfer2}, + accounts::CloseTokenAccountAccounts, processor::validate_token_account_for_close_transfer2, }, transfer2::accounts::Transfer2Accounts, }; @@ -168,33 +168,58 @@ fn validate_compressed_token_account( /// Close ctoken accounts after compress and close operations pub fn close_for_compress_and_close( compressions: &[ZCompression<'_>], - validated_accounts: &Transfer2Accounts, + _validated_accounts: &Transfer2Accounts, ) -> Result<(), ProgramError> { + // Track used compressed account indices for CompressAndClose to prevent duplicate outputs + let mut used_compressed_account_indices = [0u8; 32]; // 256 bits + let used_bits = used_compressed_account_indices.view_bits_mut::(); + for compression in compressions .iter() .filter(|c| c.mode == ZCompressionMode::CompressAndClose) { - let token_account_info = validated_accounts.packed_accounts.get_u8( - compression.source_or_recipient, - "CompressAndClose: source_or_recipient", - )?; - let destination = validated_accounts.packed_accounts.get_u8( - compression.get_destination_index()?, - "CompressAndClose: destination", - )?; - let rent_sponsor = validated_accounts.packed_accounts.get_u8( - compression.get_rent_sponsor_index()?, - "CompressAndClose: rent_sponsor", - )?; - let authority = validated_accounts - .packed_accounts - .get_u8(compression.authority, "CompressAndClose: authority")?; - close_token_account(&CloseTokenAccountAccounts { - token_account: token_account_info, - destination, - authority, - rent_sponsor: Some(rent_sponsor), - })?; + // Check for duplicate compressed account indices in CompressAndClose operations + let compressed_idx = compression.get_compressed_token_account_index()?; + if let Some(mut bit) = used_bits.get_mut(compressed_idx as usize) { + if *bit { + msg!( + "Duplicate compressed account index {} in CompressAndClose operations", + compressed_idx + ); + return Err(ErrorCode::CompressAndCloseDuplicateOutput.into()); + } + *bit = true; + } else { + msg!("Compressed account index {} out of bounds", compressed_idx); + return Err(ProgramError::InvalidInstructionData); + } + + #[cfg(target_os = "solana")] + { + let validated_accounts = _validated_accounts; + let token_account_info = validated_accounts.packed_accounts.get_u8( + compression.source_or_recipient, + "CompressAndClose: source_or_recipient", + )?; + let destination = validated_accounts.packed_accounts.get_u8( + compression.get_destination_index()?, + "CompressAndClose: destination", + )?; + let rent_sponsor = validated_accounts.packed_accounts.get_u8( + compression.get_rent_sponsor_index()?, + "CompressAndClose: rent_sponsor", + )?; + let authority = validated_accounts + .packed_accounts + .get_u8(compression.authority, "CompressAndClose: authority")?; + use crate::close_token_account::processor::close_token_account; + close_token_account(&CloseTokenAccountAccounts { + token_account: token_account_info, + destination, + authority, + rent_sponsor: Some(rent_sponsor), + })?; + } } Ok(()) } diff --git a/programs/compressed-token/program/tests/compress_and_close.rs b/programs/compressed-token/program/tests/compress_and_close.rs new file mode 100644 index 0000000000..56ec9313f2 --- /dev/null +++ b/programs/compressed-token/program/tests/compress_and_close.rs @@ -0,0 +1,151 @@ +use anchor_compressed_token::ErrorCode; +use anchor_lang::AnchorSerialize; +use light_account_checks::{ + account_info::test_account_info::pinocchio::get_account_info, + packed_accounts::ProgramPackedAccounts, +}; +use light_compressed_token::transfer2::{ + accounts::Transfer2Accounts, compression::ctoken::close_for_compress_and_close, +}; +use light_ctoken_types::{ + instructions::transfer2::{Compression, CompressionMode}, + state::{CToken, CompressedTokenConfig}, +}; +use light_zero_copy::traits::{ZeroCopyAt, ZeroCopyNew}; +use pinocchio::pubkey::Pubkey; + +/// Helper to create valid compressible CToken account data +fn create_compressible_ctoken_data( + owner_pubkey: &[u8; 32], + rent_sponsor_pubkey: &[u8; 32], +) -> Vec { + // Create config for compressible CToken (no delegate, not native, no close_authority) + let config = CompressedTokenConfig::new_compressible(false, false, false); + + // Calculate required size + let size = CToken::byte_len(&config).unwrap(); + let mut data = vec![0u8; size]; + + // Initialize using zero-copy new + let (mut ctoken, _) = CToken::new_zero_copy(&mut data, config).unwrap(); + + // Set required fields using to_bytes/to_bytes_mut methods + *ctoken.mint = light_compressed_account::Pubkey::from([0u8; 32]); + *ctoken.owner = light_compressed_account::Pubkey::from(*owner_pubkey); + *ctoken.state = 1; // AccountState::Initialized + + // Set compressible extension fields + if let Some(extensions) = ctoken.extensions.as_mut() { + if let Some(light_ctoken_types::state::ZExtensionStructMut::Compressible(comp_ext)) = + extensions.first_mut() + { + comp_ext.config_account_version.set(1); + comp_ext.account_version = 3; // ShaFlat + comp_ext.compression_authority.copy_from_slice(owner_pubkey); + comp_ext.rent_sponsor.copy_from_slice(rent_sponsor_pubkey); + comp_ext.last_claimed_slot.set(0); + } + } + + data +} + +/// Test that close_for_compress_and_close detects duplicate compressed account indices +#[test] +fn test_close_for_compress_and_close_duplicate_detection() { + // Create two CompressAndClose compressions with the SAME compressed_account_index (0) + let compressions = vec![ + Compression { + mode: CompressionMode::CompressAndClose, + amount: 500, + mint: 0, + source_or_recipient: 0, // token_account index + authority: 1, + pool_account_index: 2, // rent_sponsor index + pool_index: 0, // DUPLICATE: compressed_account_index = 0 + bump: 3, // destination index + }, + Compression { + mode: CompressionMode::CompressAndClose, + amount: 300, + mint: 0, + source_or_recipient: 4, // different token_account index + authority: 1, + pool_account_index: 2, // rent_sponsor index + pool_index: 0, // DUPLICATE: compressed_account_index = 0 (SAME AS FIRST!) + bump: 3, // destination index + }, + ]; + + // Serialize to bytes + let compression_bytes = compressions.try_to_vec().unwrap(); + + // Convert to zero-copy slice + let (compressions_zc, _) = Vec::::zero_copy_at(&compression_bytes).unwrap(); + + // Create mock account infos (we need at least 5 accounts for indices 0-4) + let owner_pubkey_bytes = [1u8; 32]; + let rent_sponsor_pubkey_bytes = [2u8; 32]; + let owner_pubkey = Pubkey::from(owner_pubkey_bytes); + let rent_sponsor_pubkey = Pubkey::from(rent_sponsor_pubkey_bytes); + let dummy_owner = [0u8; 32]; + + // Create valid compressible CToken account data using zero-copy initialization + let ctoken_data = + create_compressible_ctoken_data(&owner_pubkey_bytes, &rent_sponsor_pubkey_bytes); + + let accounts = vec![ + get_account_info( + owner_pubkey, + dummy_owner, + false, + true, + false, + ctoken_data.clone(), + ), // index 0: token_account (writable) + get_account_info(owner_pubkey, dummy_owner, true, false, false, vec![]), // index 1: authority (signer) + get_account_info(rent_sponsor_pubkey, dummy_owner, false, true, false, vec![]), // index 2: rent_sponsor (writable) + get_account_info( + Pubkey::from([3u8; 32]), + dummy_owner, + false, + true, + false, + vec![], + ), // index 3: destination (writable) + get_account_info(owner_pubkey, dummy_owner, false, true, false, ctoken_data), // index 4: second token_account (writable) + ]; + + let packed_accounts = ProgramPackedAccounts { + accounts: &accounts, + }; + + // Create minimal Transfer2Accounts + let validated_accounts = Transfer2Accounts { + system: None, + write_to_cpi_context_system: None, + compressions_only_fee_payer: None, + compressions_only_cpi_authority_pda: None, + packed_accounts, + }; + + // Call the function - should detect duplicate and return error + let result = close_for_compress_and_close(&compressions_zc, &validated_accounts); + + // Assert we got the expected error + match result { + Err(anchor_lang::prelude::ProgramError::Custom(code)) => { + assert_eq!( + code, + ErrorCode::CompressAndCloseDuplicateOutput as u32, + "Expected CompressAndCloseDuplicateOutput error, got error code: {}", + code + ); + } + Err(e) => panic!( + "Expected CompressAndCloseDuplicateOutput error, got different error type: {:?}", + e + ), + Ok(_) => panic!("Expected CompressAndCloseDuplicateOutput error, but function succeeded!"), + } +} diff --git a/programs/registry/Cargo.toml b/programs/registry/Cargo.toml index aa2a1a6ab7..0e03251499 100644 --- a/programs/registry/Cargo.toml +++ b/programs/registry/Cargo.toml @@ -25,8 +25,16 @@ aligned-sized = { workspace = true } anchor-lang = { workspace = true, features = ["init-if-needed"] } account-compression = { workspace = true } light-compressible = { workspace = true, features = ["anchor"] } -light-compressed-token-sdk = { workspace = true , features = ["anchor"]} +light-ctoken-types = { workspace = true, features = ["anchor"] } light-system-program-anchor = { workspace = true, features = ["cpi"] } +light-account-checks = { workspace = true, features = ["solana", "std", "msg"] } +light-program-profiler = { workspace = true } +light-macros = { workspace = true } +borsh = { workspace = true } +solana-account-info = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +spl-pod = { workspace = true } solana-security-txt = "1.1.0" light-merkle-tree-metadata = { workspace = true, features = ["anchor"] } light-batched-merkle-tree = { workspace = true } diff --git a/programs/registry/src/compressible/compress_and_close.rs b/programs/registry/src/compressible/compress_and_close.rs index 6067da5012..1a73394706 100644 --- a/programs/registry/src/compressible/compress_and_close.rs +++ b/programs/registry/src/compressible/compress_and_close.rs @@ -1,8 +1,13 @@ use anchor_lang::prelude::*; -use light_compressed_token_sdk::instructions::compress_and_close::CompressAndCloseIndices; use light_compressible::config::CompressibleConfig; -use crate::errors::RegistryError; +use crate::{ + compressible::compressed_token::{ + compress_and_close_ctoken_accounts_with_indices, CompressAndCloseIndices, + Transfer2CpiAccounts, + }, + errors::RegistryError, +}; #[derive(Accounts)] pub struct CompressAndCloseContext<'info> { @@ -24,14 +29,12 @@ pub struct CompressAndCloseContext<'info> { has_one = compression_authority )] pub compressible_config: Account<'info, CompressibleConfig>, - - /// Compressed token program - /// CHECK: Must be the compressed token program ID - pub compressed_token_program: AccountInfo<'info>, } -pub fn process_compress_and_close<'info>( - ctx: &Context<'_, '_, '_, 'info, CompressAndCloseContext<'info>>, +pub fn process_compress_and_close<'c: 'info, 'info>( + ctx: &Context<'_, '_, 'c, 'info, CompressAndCloseContext<'info>>, + authority_index: u8, + destination_index: u8, indices: Vec, ) -> Result<()> { // Validate config is not inactive (active or deprecated allowed for compress and close) @@ -45,25 +48,18 @@ pub fn process_compress_and_close<'info>( let fee_payer = ctx.accounts.authority.to_account_info(); - // Use the new Transfer2CpiAccounts to parse accounts + // Use Transfer2CpiAccounts to parse accounts let transfer2_accounts = - light_compressed_token_sdk::instructions::transfer2::Transfer2CpiAccounts::try_from_account_infos( - &fee_payer, - ctx.remaining_accounts - ).map_err(|_| ProgramError::InvalidAccountData)?; - - // Get the packed accounts from the parsed structure - let packed_accounts = transfer2_accounts.packed_accounts(); + Transfer2CpiAccounts::try_from_account_infos(fee_payer, ctx.remaining_accounts) + .map_err(ProgramError::from)?; - // Use the SDK's compress_and_close function with the provided indices - // Use the authority as fee_payer - let instruction = light_compressed_token_sdk::instructions::compress_and_close::compress_and_close_ctoken_accounts_with_indices( + let instruction = compress_and_close_ctoken_accounts_with_indices( ctx.accounts.authority.key(), - true, - None, // cpi_context_pubkey + authority_index, + destination_index, &indices, - packed_accounts, - ).map_err(ProgramError::from)?; + &transfer2_accounts.packed_accounts, + )?; // Prepare signer seeds for compression_authority PDA let version_bytes = ctx.accounts.compressible_config.version.to_le_bytes(); diff --git a/programs/registry/src/compressible/compressed_token/accounts.rs b/programs/registry/src/compressible/compressed_token/accounts.rs new file mode 100644 index 0000000000..528b6d9eab --- /dev/null +++ b/programs/registry/src/compressible/compressed_token/accounts.rs @@ -0,0 +1,106 @@ +use light_account_checks::{ + packed_accounts::ProgramPackedAccounts, AccountError, AccountInfoTrait, AccountIterator, +}; +use light_program_profiler::profile; + +use super::{ + ACCOUNT_COMPRESSION_AUTHORITY_PDA, ACCOUNT_COMPRESSION_PROGRAM_ID, COMPRESSED_TOKEN_PROGRAM_ID, + LIGHT_SYSTEM_PROGRAM_ID, +}; + +/// Parsed Transfer2 CPI accounts for structured access +pub struct Transfer2CpiAccounts<'a, A: AccountInfoTrait + Clone> { + // Programs and authorities (in order) + pub compressed_token_program: &'a A, + pub light_system_program: &'a A, + + // Core system accounts + pub fee_payer: A, + pub compressed_token_cpi_authority: &'a A, + pub registered_program_pda: &'a A, + pub account_compression_authority: &'a A, + pub account_compression_program: &'a A, + pub system_program: &'a A, + /// Packed accounts (trees, queues, mints, owners, delegates, etc) + /// Trees and queues must be first. + pub packed_accounts: ProgramPackedAccounts<'a, A>, +} + +impl<'a, A: AccountInfoTrait + Clone> Transfer2CpiAccounts<'a, A> { + /// Checks in this function are for convenience and not security critical. + #[profile] + #[inline(always)] + pub fn try_from_account_infos(fee_payer: A, accounts: &'a [A]) -> Result { + let mut iter = AccountIterator::new(accounts); + let compressed_token_program = iter.next_checked_pubkey( + "compressed_token_program", + COMPRESSED_TOKEN_PROGRAM_ID.to_bytes(), + )?; + + let compressed_token_cpi_authority = iter.next_account("compressed_token_cpi_authority")?; + + let light_system_program = + iter.next_checked_pubkey("light_system_program", LIGHT_SYSTEM_PROGRAM_ID.to_bytes())?; + + let registered_program_pda = iter.next_account("registered_program_pda")?; + + let account_compression_authority = iter.next_checked_pubkey( + "account_compression_authority", + ACCOUNT_COMPRESSION_AUTHORITY_PDA.to_bytes(), + )?; + + let account_compression_program = iter.next_checked_pubkey( + "account_compression_program", + ACCOUNT_COMPRESSION_PROGRAM_ID.to_bytes(), + )?; + + let system_program = iter.next_checked_pubkey("system_program", [0u8; 32])?; + + let packed_accounts = iter.remaining()?; + if !packed_accounts[0].is_owned_by(&ACCOUNT_COMPRESSION_PROGRAM_ID.to_bytes()) { + use anchor_lang::prelude::msg; + msg!("First packed accounts must be tree or queue accounts."); + msg!("Found {:?} instead", packed_accounts[0].pubkey()); + return Err(AccountError::InvalidAccount); + } + + Ok(Self { + compressed_token_program, + light_system_program, + fee_payer, + compressed_token_cpi_authority, + registered_program_pda, + account_compression_authority, + account_compression_program, + system_program, + packed_accounts: ProgramPackedAccounts { + accounts: packed_accounts, + }, + }) + } + + /// Get accounts for CPI to light system program (excludes the programs themselves) + #[profile] + #[inline(always)] + pub fn to_account_infos(&self) -> Vec { + let mut accounts = Vec::with_capacity(7 + self.packed_accounts.accounts.len()); + + accounts.extend_from_slice( + &[ + self.light_system_program.clone(), + self.fee_payer.clone(), + self.compressed_token_cpi_authority.clone(), + self.registered_program_pda.clone(), + self.account_compression_authority.clone(), + self.account_compression_program.clone(), + self.system_program.clone(), + ][..], + ); + + self.packed_accounts.accounts.iter().for_each(|e| { + accounts.push(e.clone()); + }); + + accounts + } +} diff --git a/programs/registry/src/compressible/compressed_token/compress_and_close.rs b/programs/registry/src/compressible/compressed_token/compress_and_close.rs new file mode 100644 index 0000000000..4f1e43a55f --- /dev/null +++ b/programs/registry/src/compressible/compressed_token/compress_and_close.rs @@ -0,0 +1,179 @@ +use anchor_lang::{prelude::ProgramError, pubkey, AnchorDeserialize, AnchorSerialize, Result}; +use light_account_checks::packed_accounts::ProgramPackedAccounts; +use light_ctoken_types::{ + instructions::transfer2::{ + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, + MultiTokenTransferOutputData, + }, + state::CToken, +}; +use light_program_profiler::profile; +use solana_account_info::AccountInfo; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; +use spl_pod::solana_msg::msg; + +use crate::errors::RegistryError; + +const TRANSFER2_DISCRIMINATOR: u8 = 101; +use super::{ + ACCOUNT_COMPRESSION_AUTHORITY_PDA, ACCOUNT_COMPRESSION_PROGRAM_ID, COMPRESSED_TOKEN_PROGRAM_ID, + LIGHT_SYSTEM_PROGRAM_ID, REGISTERED_PROGRAM_PDA, +}; + +pub const CPI_AUTHORITY_PDA: Pubkey = pubkey!("GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy"); + +/// Struct to hold all the indices needed for CompressAndClose operation +#[derive(Debug, Copy, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct CompressAndCloseIndices { + pub source_index: u8, + pub mint_index: u8, + pub owner_index: u8, + pub rent_sponsor_index: u8, // Can vary with custom rent sponsors +} + +/// Compress and close compressed token accounts with pre-computed indices +/// +/// This function is designed for on-chain use (e.g., from registry program). +/// It reads account data, builds Compression structs manually, and constructs +/// the Transfer2 instruction with all necessary accounts. +/// +/// # Arguments +/// * `fee_payer` - The fee payer pubkey +/// * `cpi_context_pubkey` - Optional CPI context account for optimized multi-program transactions +/// * `authority_index` - Index of compression authority in packed_accounts +/// * `destination_index` - Index of compression incentive destination in packed_accounts +/// * `indices` - Slice of per-account indices (source, mint, owner, rent_sponsor) +/// * `packed_accounts` - Slice of all accounts (AccountInfo) that will be used in the instruction +/// +/// # Returns +/// An instruction that compresses and closes all provided token accounts +#[profile] +pub fn compress_and_close_ctoken_accounts_with_indices<'info>( + fee_payer: Pubkey, + authority_index: u8, + destination_index: u8, + indices: &[CompressAndCloseIndices], + packed_accounts: &ProgramPackedAccounts<'info, AccountInfo<'info>>, +) -> Result { + if indices.is_empty() { + msg!("indices empty"); + return Err(ProgramError::NotEnoughAccountKeys.into()); + } + + // Convert packed_accounts to AccountMetas + let mut packed_account_metas = Vec::with_capacity(packed_accounts.accounts.len()); + for info in packed_accounts.accounts.iter() { + packed_account_metas.push(AccountMeta { + pubkey: *info.key, + is_signer: info.is_signer, + is_writable: info.is_writable, + }); + } + + // Create one output per compression (no deduplication) + let mut output_accounts = Vec::with_capacity(indices.len()); + let mut compressions = Vec::with_capacity(indices.len()); + + // Process each set of indices + for (i, idx) in indices.iter().enumerate() { + // Get the amount from the source token account + let source_account = packed_accounts + .get_u8(idx.source_index, "source_account") + .map_err(ProgramError::from)?; + + let account_data = source_account + .try_borrow_data() + .map_err(|_| RegistryError::InvalidSigner)?; + + let amount = CToken::amount_from_slice(&account_data).map_err(|e| { + anchor_lang::prelude::msg!("Failed to read amount from CToken: {:?}", e); + RegistryError::InvalidSigner + })?; + + // Create one output account per compression operation + output_accounts.push(MultiTokenTransferOutputData { + owner: idx.owner_index, + amount, + delegate: 0, + mint: idx.mint_index, + version: 3, // Shaflat + has_delegate: false, + }); + + let compression = Compression { + mode: CompressionMode::CompressAndClose, + amount, + mint: idx.mint_index, + source_or_recipient: idx.source_index, + authority: authority_index, + pool_account_index: idx.rent_sponsor_index, + pool_index: i as u8, + bump: destination_index, + }; + + compressions.push(compression); + } + + packed_account_metas + .get_mut(authority_index as usize) + .ok_or(ProgramError::NotEnoughAccountKeys)? + .is_signer = true; + + // Build instruction data inline + let instruction_data = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 0, // Output queue is at index 0 in packed_accounts + proof: None, + in_token_data: vec![], // No inputs for compress_and_close + out_token_data: output_accounts, + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + compressions: Some(compressions), + cpi_context: None, + }; + + // Serialize instruction data + let serialized = instruction_data + .try_to_vec() + .map_err(|_| RegistryError::InvalidSigner)?; + + // Build instruction data with discriminator + let mut data = Vec::with_capacity(1 + serialized.len()); + data.push(TRANSFER2_DISCRIMINATOR); + data.extend(serialized); + + // Build account metas following Transfer2 accounts layout + let mut account_metas = Vec::with_capacity(10 + packed_account_metas.len()); + + // Core system accounts + account_metas.push(AccountMeta::new_readonly(LIGHT_SYSTEM_PROGRAM_ID, false)); + account_metas.push(AccountMeta::new(fee_payer, true)); // fee_payer (signer) + account_metas.push(AccountMeta::new_readonly(CPI_AUTHORITY_PDA, false)); + account_metas.push(AccountMeta::new_readonly(REGISTERED_PROGRAM_PDA, false)); + account_metas.push(AccountMeta::new_readonly( + ACCOUNT_COMPRESSION_AUTHORITY_PDA, + false, + )); + account_metas.push(AccountMeta::new_readonly( + ACCOUNT_COMPRESSION_PROGRAM_ID, + false, + )); + account_metas.push(AccountMeta::new_readonly( + Pubkey::from([0u8; 32]), // system_program + false, + )); + // Packed accounts (trees, queues, mints, owners, etc.) + account_metas.extend(packed_account_metas); + + Ok(Instruction { + program_id: COMPRESSED_TOKEN_PROGRAM_ID, + accounts: account_metas, + data, + }) +} diff --git a/programs/registry/src/compressible/compressed_token/mod.rs b/programs/registry/src/compressible/compressed_token/mod.rs new file mode 100644 index 0000000000..f26dc2e503 --- /dev/null +++ b/programs/registry/src/compressible/compressed_token/mod.rs @@ -0,0 +1,27 @@ +pub mod accounts; +pub mod compress_and_close; + +pub use accounts::Transfer2CpiAccounts; +use anchor_lang::pubkey; +pub use compress_and_close::{ + compress_and_close_ctoken_accounts_with_indices, CompressAndCloseIndices, +}; +use solana_pubkey::Pubkey; + +// Program ID for light-compressed-token +pub const COMPRESSED_TOKEN_PROGRAM_ID: Pubkey = + pubkey!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"); + +// Light System Program ID +pub const LIGHT_SYSTEM_PROGRAM_ID: Pubkey = pubkey!("SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7"); + +// Account Compression Program ID +pub const ACCOUNT_COMPRESSION_PROGRAM_ID: Pubkey = + pubkey!("compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq"); + +// Account Compression Authority PDA +pub const ACCOUNT_COMPRESSION_AUTHORITY_PDA: Pubkey = + pubkey!("HwXnGK3tPkkVY6P439H2p68AxpeuWXd5PcrAxFpbmfbA"); + +// Registered Program PDA +pub const REGISTERED_PROGRAM_PDA: Pubkey = pubkey!("35hkDgaAKwMCaxRz2ocSZ6NaUrtKkyNqU6c4RV3tYJRh"); diff --git a/programs/registry/src/compressible/mod.rs b/programs/registry/src/compressible/mod.rs index 8c5ba444e3..700ecd9eed 100644 --- a/programs/registry/src/compressible/mod.rs +++ b/programs/registry/src/compressible/mod.rs @@ -1,5 +1,6 @@ pub mod claim; pub mod compress_and_close; +pub mod compressed_token; pub mod create_config; pub mod create_config_counter; pub mod update_config; diff --git a/programs/registry/src/lib.rs b/programs/registry/src/lib.rs index 8d30c65d6d..8da1235cc3 100644 --- a/programs/registry/src/lib.rs +++ b/programs/registry/src/lib.rs @@ -45,9 +45,10 @@ use light_compressible::registry_instructions::{ }; use protocol_config::state::ProtocolConfig; pub use selection::forester::*; + +use crate::compressible::compressed_token::CompressAndCloseIndices; #[cfg(not(target_os = "solana"))] pub mod sdk; -use light_compressed_token_sdk::instructions::compress_and_close::CompressAndCloseIndices; #[cfg(not(feature = "no-entrypoint"))] solana_security_txt::security_txt! { @@ -781,8 +782,10 @@ pub mod light_registry { } /// Compress and close token accounts via transfer2 - pub fn compress_and_close<'info>( - ctx: Context<'_, '_, '_, 'info, CompressAndCloseContext<'info>>, + pub fn compress_and_close<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, CompressAndCloseContext<'info>>, + authority_index: u8, + destination_index: u8, indices: Vec, ) -> Result<()> { // Check forester and track work @@ -793,7 +796,7 @@ pub mod light_registry { &Pubkey::default(), 0, )?; - process_compress_and_close(&ctx, indices) + process_compress_and_close(&ctx, authority_index, destination_index, indices) } } diff --git a/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs b/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs index 26c141545e..e326f2c5ab 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs @@ -26,7 +26,7 @@ use crate::{ }; /// Struct to hold all the indices needed for CompressAndClose operation -#[derive(Debug, crate::AnchorSerialize, crate::AnchorDeserialize)] +#[derive(Debug, Copy, Clone, crate::AnchorSerialize, crate::AnchorDeserialize)] pub struct CompressAndCloseIndices { pub source_index: u8, pub mint_index: u8, diff --git a/sdk-libs/program-test/src/forester/compress_and_close_forester.rs b/sdk-libs/program-test/src/forester/compress_and_close_forester.rs index d12d253c7e..0f0639b55e 100644 --- a/sdk-libs/program-test/src/forester/compress_and_close_forester.rs +++ b/sdk-libs/program-test/src/forester/compress_and_close_forester.rs @@ -5,12 +5,11 @@ use light_client::{ indexer::Indexer, rpc::{Rpc, RpcError}, }; -use light_compressed_token_sdk::instructions::compress_and_close::{ - CompressAndCloseAccounts as CTokenCompressAndCloseAccounts, CompressAndCloseIndices, -}; +use light_compressed_token_sdk::instructions::compress_and_close::CompressAndCloseAccounts as CTokenCompressAndCloseAccounts; use light_compressible::config::CompressibleConfig; use light_registry::{ - accounts::CompressAndCloseContext as CompressAndCloseAccounts, instruction::CompressAndClose, + accounts::CompressAndCloseContext as CompressAndCloseAccounts, + compressible::compressed_token::CompressAndCloseIndices, instruction::CompressAndClose, utils::get_forester_epoch_pda_from_authority, }; use light_sdk::instruction::PackedAccounts; @@ -87,9 +86,10 @@ pub async fn compress_and_close_forester( use light_ctoken_types::state::{CToken, ZExtensionStruct}; use light_zero_copy::traits::ZeroCopyAt; - // Process each token account and build indices let mut indices_vec = Vec::with_capacity(solana_ctoken_accounts.len()); + let mut compression_authority_pubkey: Option = None; + for solana_ctoken_account_pubkey in solana_ctoken_accounts { // Get the ctoken account data let ctoken_solana_account = rpc @@ -121,28 +121,20 @@ pub async fn compress_and_close_forester( let mint_index = packed_accounts.insert_or_get(Pubkey::from(ctoken_account.mint.to_bytes())); - // Default owner is the ctoken account owner let mut compressed_token_owner = Pubkey::from(ctoken_account.owner.to_bytes()); - - // For registry flow: compression_authority is a PDA (not a signer in transaction) - // Find compression_authority, rent_sponsor, and compress_to_pubkey from extension - let mut compression_authority_pubkey = Pubkey::from(ctoken_account.owner.to_bytes()); let mut rent_sponsor_pubkey = Pubkey::from(ctoken_account.owner.to_bytes()); if let Some(extensions) = &ctoken_account.extensions { for extension in extensions { if let ZExtensionStruct::Compressible(e) = extension { - compression_authority_pubkey = Pubkey::from(e.compression_authority); + let current_authority = Pubkey::from(e.compression_authority); rent_sponsor_pubkey = Pubkey::from(e.rent_sponsor); - println!( - "compression_authority_pubkey {:?}", - compression_authority_pubkey - ); - println!("compress to pubkey {}", e.compress_to_pubkey()); - // Check if compress_to_pubkey is set + if compression_authority_pubkey.is_none() { + compression_authority_pubkey = Some(current_authority); + } + if e.compress_to_pubkey() { - // Use the compress_to_pubkey as the owner for compressed tokens compressed_token_owner = *solana_ctoken_account_pubkey; } break; @@ -150,45 +142,29 @@ pub async fn compress_and_close_forester( } } - // Pack the owner and rent_sponsor indices let owner_index = packed_accounts.insert_or_get(compressed_token_owner); let rent_sponsor_index = packed_accounts.insert_or_get(rent_sponsor_pubkey); - // Add compression_authority as non-signer (registry will sign with PDA) - let authority_index = packed_accounts.insert_or_get_config( - compression_authority_pubkey, - false, // is_signer = false (registry PDA will sign during CPI) - true, // is_writable - ); - - // Add destination for compression incentive (defaults to payer if not specified) - let destination_pubkey = destination.unwrap_or_else(|| payer.pubkey()); - println!( - "compress_and_close_forester destination pubkey: {:?}", - destination_pubkey - ); - let destination_index = packed_accounts.insert_or_get_config( - destination_pubkey, - false, // Already signed at transaction level if it's the payer - true, // is_writable to receive lamports - ); - let indices = CompressAndCloseIndices { source_index, mint_index, owner_index, - authority_index, rent_sponsor_index, - destination_index, // Compression incentive goes to destination (forester) }; indices_vec.push(indices); } - // Add light system program accounts - // NOTE: Do NOT set self_program when calling through registry! - // The registry will handle the CPI authority, so we don't want the light_system_cpi_authority - // to be added to the accounts (it would be at the wrong position for Transfer2CpiAccounts parsing) + let destination_pubkey = destination.unwrap_or_else(|| payer.pubkey()); + let destination_index = packed_accounts.insert_or_get_config(destination_pubkey, false, true); + + let compression_authority_pubkey = compression_authority_pubkey.ok_or_else(|| { + RpcError::CustomError("No compression authority found in accounts".to_string()) + })?; + + let authority_index = + packed_accounts.insert_or_get_config(compression_authority_pubkey, false, true); + let config = CTokenCompressAndCloseAccounts { compressed_token_program: compressed_token_program_id, cpi_authority_pda: Pubkey::find_program_address( @@ -211,16 +187,16 @@ pub async fn compress_and_close_forester( registered_forester_pda, compression_authority, compressible_config, - compressed_token_program: compressed_token_program_id, }; // Get account metas from Anchor accounts let mut accounts = compress_and_close_accounts.to_account_metas(Some(true)); - // Add remaining accounts from packed accounts accounts.extend(remaining_account_metas); - // Create Anchor instruction with proper discriminator + let instruction = CompressAndClose { + authority_index, + destination_index, indices: indices_vec, }; let instruction_data = instruction.data();