diff --git a/program-libs/ctoken-types/src/instructions/mint_action/mint_to_ctoken.rs b/program-libs/ctoken-types/src/instructions/mint_action/mint_to_ctoken.rs index 710a3afe70..72e0dfca4a 100644 --- a/program-libs/ctoken-types/src/instructions/mint_action/mint_to_ctoken.rs +++ b/program-libs/ctoken-types/src/instructions/mint_action/mint_to_ctoken.rs @@ -3,14 +3,8 @@ use light_zero_copy::ZeroCopy; use crate::{AnchorDeserialize, AnchorSerialize}; #[repr(C)] -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, ZeroCopy)] -pub struct DecompressedRecipient { +#[derive(Debug, Clone, Copy, AnchorSerialize, AnchorDeserialize, ZeroCopy)] +pub struct MintToCTokenAction { pub account_index: u8, // Index into remaining accounts for the recipient token account pub amount: u64, } - -#[repr(C)] -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, ZeroCopy)] -pub struct MintToCTokenAction { - pub recipient: DecompressedRecipient, -} diff --git a/program-tests/compressed-token-test/tests/mint/edge_cases.rs b/program-tests/compressed-token-test/tests/mint/edge_cases.rs index ebec14349a..0ab7e7ffc5 100644 --- a/program-tests/compressed-token-test/tests/mint/edge_cases.rs +++ b/program-tests/compressed-token-test/tests/mint/edge_cases.rs @@ -1,9 +1,13 @@ use anchor_lang::prelude::borsh::BorshDeserialize; use light_client::indexer::Indexer; use light_compressed_token_sdk::instructions::{ + create_associated_token_account::{ + create_compressible_associated_token_account, + CreateCompressibleAssociatedTokenAccountInputs, + }, derive_compressed_mint_address, find_spl_mint_address, }; -use light_ctoken_types::state::{extensions::AdditionalMetadata, CompressedMint}; +use light_ctoken_types::state::{extensions::AdditionalMetadata, CompressedMint, TokenDataVersion}; use light_program_test::{LightProgramTest, ProgramTestConfig}; use light_test_utils::{ assert_mint_action::assert_mint_action, mint_assert::assert_compressed_mint_account, Rpc, @@ -139,16 +143,26 @@ async fn functional_all_in_one_instruction() { let new_freeze_authority = Keypair::new(); let new_metadata_authority = Keypair::new(); - // Create a ctoken account for MintToCToken + // Create a compressible ctoken account for MintToCToken let recipient = Keypair::new(); - let create_ata_ix = light_compressed_token_sdk::instructions::create_associated_token_account( - payer.pubkey(), - recipient.pubkey(), - spl_mint_pda, + let create_compressible_ata_ix = create_compressible_associated_token_account( + CreateCompressibleAssociatedTokenAccountInputs { + payer: payer.pubkey(), + owner: recipient.pubkey(), + mint: spl_mint_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, + lamports_per_write: Some(1000), + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + token_account_version: TokenDataVersion::ShaFlat, + }, ) .unwrap(); - rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + rpc.create_and_send_transaction(&[create_compressible_ata_ix], &payer.pubkey(), &[&payer]) .await .unwrap(); diff --git a/program-tests/utils/src/assert_mint_action.rs b/program-tests/utils/src/assert_mint_action.rs index 1dc07af81d..a19dda082a 100644 --- a/program-tests/utils/src/assert_mint_action.rs +++ b/program-tests/utils/src/assert_mint_action.rs @@ -5,10 +5,10 @@ use light_client::indexer::Indexer; use light_compressed_token_sdk::instructions::mint_action::MintActionType; use light_ctoken_types::state::{ extensions::{AdditionalMetadata, ExtensionStruct}, - CompressedMint, + CToken, CompressedMint, }; -use light_program_test::LightProgramTest; -use solana_sdk::{program_pack::Pack, pubkey::Pubkey}; +use light_program_test::{LightProgramTest, Rpc}; +use solana_sdk::pubkey::Pubkey; /// Assert that mint actions produce the expected state changes /// @@ -141,22 +141,88 @@ pub async fn assert_mint_action( let pre_account = rpc .get_pre_transaction_account(&account_pubkey) .expect("CToken account should exist before minting"); - let mut expected_token_account = - spl_token::state::Account::unpack(&pre_account.data[..165]).unwrap(); + + // Parse pre-transaction CToken state + let mut pre_ctoken: CToken = + BorshDeserialize::deserialize(&mut &pre_account.data[..]).unwrap(); // Apply the total minted amount (handles multiple mints to same account) - expected_token_account.amount += total_minted_amount; + pre_ctoken.amount = pre_ctoken + .amount + .checked_add(total_minted_amount) + .expect("Token amount overflow"); // Get actual post-transaction account let account_data = rpc.context.get_account(&account_pubkey).unwrap(); - let actual_token_account = - spl_token::state::Account::unpack(&account_data.data[..165]).unwrap(); + let post_ctoken: CToken = + BorshDeserialize::deserialize(&mut &account_data.data[..]).unwrap(); - // Single assertion for complete account state + // Assert token amount matches expected assert_eq!( - actual_token_account, expected_token_account, - "CToken account state at {} should match expected after minting {} tokens", - account_pubkey, total_minted_amount + post_ctoken.amount, pre_ctoken.amount, + "CToken account state at {} should have {} tokens after minting, got {}", + account_pubkey, pre_ctoken.amount, post_ctoken.amount ); + + // Validate lamport balance changes for compressible accounts + let pre_lamports = pre_account.lamports; + let post_lamports = account_data.lamports; + + // Check if account has compressible extension (reuse pre_ctoken parsed earlier) + if let Some(extensions) = pre_ctoken.extensions.as_ref() { + // Look for compressible extension + let compressible_ext = extensions.iter().find_map(|ext| { + if let ExtensionStruct::Compressible(comp) = ext { + Some(comp) + } else { + None + } + }); + + if let Some(compressible) = compressible_ext { + // Account has compressible extension - calculate expected top-up + let current_slot = rpc.get_slot().await.unwrap(); + let account_size = pre_account.data.len() as u64; + + let expected_top_up = compressible + .calculate_top_up_lamports( + account_size, + current_slot, + pre_lamports, + compressible.lamports_per_write, + light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + ) + .unwrap(); + + let actual_lamport_change = post_lamports + .checked_sub(pre_lamports) + .expect("Post lamports should be >= pre lamports"); + + assert_eq!( + actual_lamport_change, expected_top_up, + "CToken account at {} should receive {} lamports top-up for compressible extension, got {}", + account_pubkey, expected_top_up, actual_lamport_change + ); + + println!( + "✓ Lamport top-up validated: {} lamports transferred to compressible ctoken account {}", + expected_top_up, account_pubkey + ); + } else { + // Has extensions but no compressible extension - lamports should not change + assert_eq!( + pre_lamports, post_lamports, + "Non-compressible CToken account at {} should not receive lamport top-up", + account_pubkey + ); + } + } else { + // No extensions - lamports should not change + assert_eq!( + pre_lamports, post_lamports, + "CToken account without extensions at {} should not receive lamport top-up", + account_pubkey + ); + } } } diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs b/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs index 7f2efbbfaa..375d05150a 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs +++ b/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs @@ -21,14 +21,14 @@ pub fn process_mint_to_ctoken_action( validated_accounts: &MintActionAccounts, packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, mint: Pubkey, -) -> Result<(), ProgramError> { +) -> Result, ProgramError> { check_authority( compressed_mint.base.mint_authority, validated_accounts.authority.key(), "mint authority", )?; - let amount = u64::from(action.recipient.amount); + let amount = u64::from(action.amount); compressed_mint.base.supply = compressed_mint .base .supply @@ -44,7 +44,7 @@ pub fn process_mint_to_ctoken_action( // Get the recipient token account from packed accounts using the index let token_account_info = - packed_accounts.get_u8(action.recipient.account_index, "ctoken mint to recipient")?; + packed_accounts.get_u8(action.account_index, "ctoken mint to recipient")?; // Authority check now performed above - safe to proceed with decompression // Use the mint_ctokens constructor for simple decompression operations @@ -54,10 +54,8 @@ pub fn process_mint_to_ctoken_action( token_account_info, packed_accounts, ); - // For mint_to_ctoken, we don't need to handle lamport transfers - // as there's no compressible extension on the target account - compress_or_decompress_ctokens(inputs)?; - Ok(()) + + compress_or_decompress_ctokens(inputs) } #[profile] diff --git a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs b/programs/compressed-token/program/src/mint_action/actions/process_actions.rs index 6211a23821..3eb6bd7012 100644 --- a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs +++ b/programs/compressed-token/program/src/mint_action/actions/process_actions.rs @@ -1,5 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; +use arrayvec::ArrayVec; use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_compressed_account::instruction_data::data::ZOutputCompressedAccountWithPackedContextMut; use light_ctoken_types::{ @@ -9,19 +10,29 @@ use light_ctoken_types::{ }; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; +use spl_pod::solana_msg::msg; -use crate::mint_action::{ - accounts::MintActionAccounts, - check_authority, - mint_to::process_mint_to_compressed_action, - mint_to_ctoken::process_mint_to_ctoken_action, - queue_indices::QueueIndices, - update_metadata::{ - process_remove_metadata_key_action, process_update_metadata_authority_action, - process_update_metadata_field_action, +use crate::{ + mint_action::{ + accounts::MintActionAccounts, + check_authority, + mint_to::process_mint_to_compressed_action, + mint_to_ctoken::process_mint_to_ctoken_action, + queue_indices::QueueIndices, + update_metadata::{ + process_remove_metadata_key_action, process_update_metadata_authority_action, + process_update_metadata_field_action, + }, + }, + shared::{ + convert_program_error, + transfer_lamports::{multi_transfer_lamports, Transfer}, }, }; +/// Maximum number of packed accounts allowed in a single instruction +const MAX_PACKED_ACCOUNTS: usize = 40; + #[allow(clippy::too_many_arguments)] #[profile] pub fn process_actions<'a>( @@ -35,6 +46,9 @@ pub fn process_actions<'a>( packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, compressed_mint: &mut CompressedMint, ) -> Result<(), ProgramError> { + // Array to accumulate transfer amounts by account index + let mut transfer_map = [0u64; MAX_PACKED_ACCOUNTS]; + // Start metadata authority with same value as mint authority for action in parsed_instruction_data.actions.iter() { match action { @@ -80,13 +94,29 @@ pub fn process_actions<'a>( // compressed_mint.metadata.spl_mint_initialized = true; } ZAction::MintToCToken(mint_to_ctoken_action) => { - process_mint_to_ctoken_action( + let transfer_amount = process_mint_to_ctoken_action( mint_to_ctoken_action, compressed_mint, validated_accounts, packed_accounts, parsed_instruction_data.mint.metadata.mint, )?; + + // Accumulate transfer amount if present (deduplication happens here) + if let Some(amount) = transfer_amount { + let account_index = mint_to_ctoken_action.account_index; + if account_index as usize >= MAX_PACKED_ACCOUNTS { + msg!( + "Too many compression transfers: {}, max {} allowed", + account_index, + MAX_PACKED_ACCOUNTS + ); + return Err(ErrorCode::TooManyCompressionTransfers.into()); + } + transfer_map[account_index as usize] = transfer_map[account_index as usize] + .checked_add(amount) + .ok_or(ProgramError::ArithmeticOverflow)?; + } } ZAction::UpdateMetadataField(update_metadata_action) => { process_update_metadata_field_action( @@ -112,5 +142,37 @@ pub fn process_actions<'a>( } } + // Build transfers array from deduplicated map + let transfers: ArrayVec = transfer_map + .iter() + .enumerate() + .filter_map(|(index, &amount)| { + if amount != 0 { + Some((index as u8, amount)) + } else { + None + } + }) + .map(|(index, amount)| { + Ok(Transfer { + account: packed_accounts.get_u8(index, "transfer account")?, + amount, + }) + }) + .collect::, ProgramError>>()?; + + // Execute transfers if any exist + if !transfers.is_empty() { + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or_else(|| { + msg!("Fee payer required for compressible token account top-ups"); + ProgramError::NotEnoughAccountKeys + })?; + multi_transfer_lamports(fee_payer, &transfers).map_err(convert_program_error)?; + } + Ok(()) } diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index df95b2e3f8..cecff31b48 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -10,9 +10,9 @@ use light_ctoken_types::{ extensions::{token_metadata::TokenMetadataInstructionData, ExtensionInstructionData}, mint_action::{ Action, CompressedMintInstructionData, CpiContext, CreateMint, CreateSplMintAction, - DecompressedRecipient, MintActionCompressedInstructionData, MintToCTokenAction, - MintToCompressedAction, Recipient, RemoveMetadataKeyAction, UpdateAuthority, - UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, + MintActionCompressedInstructionData, MintToCTokenAction, MintToCompressedAction, + Recipient, RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, + UpdateMetadataFieldAction, }, }, state::CompressedMintMetadata, @@ -72,10 +72,8 @@ fn random_mint_to_action(rng: &mut StdRng) -> MintToCompressedAction { fn random_mint_to_decompressed_action(rng: &mut StdRng) -> MintToCTokenAction { MintToCTokenAction { - recipient: DecompressedRecipient { - amount: rng.gen_range(1..=1_000_000), - account_index: rng.gen_range(1..=255), - }, + amount: rng.gen_range(1..=1_000_000), + account_index: rng.gen_range(1..=255), } } diff --git a/sdk-libs/compressed-token-sdk/src/instructions/mint_action/instruction.rs b/sdk-libs/compressed-token-sdk/src/instructions/mint_action/instruction.rs index e27aedfebf..525e440cc0 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/mint_action/instruction.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/mint_action/instruction.rs @@ -338,9 +338,7 @@ pub fn create_mint_action_cpi( })); } MintActionType::MintToCToken { account, amount } => { - use light_ctoken_types::instructions::mint_action::{ - DecompressedRecipient, MintToCTokenAction, - }; + use light_ctoken_types::instructions::mint_action::MintToCTokenAction; // Add account to decompressed accounts list and get its index decompressed_accounts.push(account); @@ -348,10 +346,8 @@ pub fn create_mint_action_cpi( decompressed_account_index += 1; program_actions.push(Action::MintToCToken(MintToCTokenAction { - recipient: DecompressedRecipient { - account_index: current_index, - amount, - }, + account_index: current_index, + amount, })); } MintActionType::UpdateMetadataField {