From ab565a8f27eb9f01f5ecc1d1340c6c7acfed98fa Mon Sep 17 00:00:00 2001 From: ananas-block Date: Thu, 4 Dec 2025 14:49:27 +0000 Subject: [PATCH 1/4] feat: add max top up to compressible config --- program-libs/compressible/src/rent/config.rs | 26 ++++++++++++-- program-libs/ctoken-types/src/error.rs | 4 +++ .../ctoken-types/tests/ctoken/spl_compat.rs | 2 +- .../tests/ctoken/create.rs | 34 ++++++++++++++++-- .../tests/ctoken/create_ata.rs | 36 +++++++++++++++++-- .../src/shared/initialize_ctoken_account.rs | 13 ++++++- 6 files changed, 106 insertions(+), 9 deletions(-) diff --git a/program-libs/compressible/src/rent/config.rs b/program-libs/compressible/src/rent/config.rs index f239f3d6da..36e720a65d 100644 --- a/program-libs/compressible/src/rent/config.rs +++ b/program-libs/compressible/src/rent/config.rs @@ -29,6 +29,9 @@ pub trait RentConfigTrait { /// Get maximum funded epochs fn max_funded_epochs(&self) -> u64; + /// Get maximum top-up amount per write operation + fn max_top_up(&self) -> u64; + /// Calculate rent per epoch for a given number of bytes #[inline(always)] fn rent_curve_per_epoch(&self, num_bytes: u64) -> u64 { @@ -72,7 +75,9 @@ pub struct RentConfig { pub compression_cost: u16, pub lamports_per_byte_per_epoch: u8, pub max_funded_epochs: u8, // once the account is funded for max_funded_epochs top up per write is not executed - pub _padding: [u8; 2], + /// Maximum lamports that can be charged per top-up operation. + /// Protects against griefing by accounts with high lamports_per_write. + pub max_top_up: u16, } impl Default for RentConfig { @@ -82,7 +87,7 @@ impl Default for RentConfig { compression_cost: COMPRESSION_COST + COMPRESSION_INCENTIVE, lamports_per_byte_per_epoch: RENT_PER_BYTE, max_funded_epochs: 2, // once the account is funded for max_funded_epochs top up per write is not executed - _padding: [0; 2], + max_top_up: 12416, // 48h rent for ctoken accounts of size 260 bytes } } } @@ -107,6 +112,11 @@ impl RentConfigTrait for RentConfig { fn max_funded_epochs(&self) -> u64 { self.max_funded_epochs as u64 } + + #[inline(always)] + fn max_top_up(&self) -> u64 { + self.max_top_up as u64 + } } impl RentConfig { @@ -142,6 +152,11 @@ impl RentConfigTrait for ZRentConfig<'_> { fn max_funded_epochs(&self) -> u64 { self.max_funded_epochs as u64 } + + #[inline(always)] + fn max_top_up(&self) -> u64 { + self.max_top_up.into() + } } // Implement trait for zero-copy mutable reference @@ -165,6 +180,11 @@ impl RentConfigTrait for ZRentConfigMut<'_> { fn max_funded_epochs(&self) -> u64 { self.max_funded_epochs as u64 } + + #[inline(always)] + fn max_top_up(&self) -> u64 { + self.max_top_up.into() + } } impl ZRentConfigMut<'_> { @@ -174,6 +194,6 @@ impl ZRentConfigMut<'_> { self.compression_cost = config.compression_cost.into(); self.lamports_per_byte_per_epoch = config.lamports_per_byte_per_epoch; self.max_funded_epochs = config.max_funded_epochs; - self._padding = config._padding; + self.max_top_up = config.max_top_up.into(); } } diff --git a/program-libs/ctoken-types/src/error.rs b/program-libs/ctoken-types/src/error.rs index 70f83d2cd0..fc78d5207a 100644 --- a/program-libs/ctoken-types/src/error.rs +++ b/program-libs/ctoken-types/src/error.rs @@ -129,6 +129,9 @@ pub enum CTokenError { #[error("Too many PDA seeds. Maximum {0} seeds allowed")] TooManySeeds(usize), + + #[error("write_top_up exceeds max_top_up from RentConfig")] + WriteTopUpExceedsMaximum, } impl From for u32 { @@ -175,6 +178,7 @@ impl From for u32 { CTokenError::TooManyAdditionalMetadata => 18039, CTokenError::DuplicateMetadataKey => 18040, CTokenError::TooManySeeds(_) => 18041, + CTokenError::WriteTopUpExceedsMaximum => 18042, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-libs/ctoken-types/tests/ctoken/spl_compat.rs b/program-libs/ctoken-types/tests/ctoken/spl_compat.rs index 38f2cd46e5..eca5cb71b7 100644 --- a/program-libs/ctoken-types/tests/ctoken/spl_compat.rs +++ b/program-libs/ctoken-types/tests/ctoken/spl_compat.rs @@ -540,7 +540,7 @@ fn test_compressible_extension_partial_eq() { compression_cost: 0, lamports_per_byte_per_epoch: 0, max_funded_epochs: 0, - _padding: [0; 2], + max_top_up: 0, }, }; diff --git a/program-tests/compressed-token-test/tests/ctoken/create.rs b/program-tests/compressed-token-test/tests/ctoken/create.rs index 3dfca03499..168da9fa0f 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create.rs @@ -126,7 +126,8 @@ async fn test_create_account_random() { } }, lamports_per_write: if rng.gen_bool(0.5) { - Some(rng.gen_range(0..=u16::MAX as u32)) + // Limit to max_top_up to avoid WriteTopUpExceedsMaximum error + Some(rng.gen_range(0..=RentConfig::default().max_top_up as u32)) } else { None }, @@ -447,7 +448,36 @@ async fn test_create_compressible_token_account_failing() { light_program_test::utils::assert::assert_rpc_error(result, 0, 20001).unwrap(); } - // Test 7: Wrong account type (correct program owner, wrong discriminator) + // Test 7: write_top_up exceeds max_top_up from RentConfig + // Accounts cannot be created with lamports_per_write > max_top_up. + // This protects against griefing attacks where recipient creates account with excessive top-up. + // Error: 18042 (WriteTopUpExceedsMaximum from CTokenError) + { + context.token_account_keypair = Keypair::new(); + + // Default max_top_up is 6208, so use 6209 to exceed it + let excessive_lamports_per_write = RentConfig::default().max_top_up as u32 + 1; + + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(excessive_lamports_per_write), + account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + + create_and_assert_token_account_fails( + &mut context, + compressible_data, + "write_top_up_exceeds_max_top_up", + 18042, // WriteTopUpExceedsMaximum from CTokenError + ) + .await; + } + + // Test 8: Wrong account type (correct program owner, wrong discriminator) // Passing an account owned by the registry program but not a CompressibleConfig. // Using the protocol config account which has a different discriminator. // Error: 2 (InvalidDiscriminator from account-checks) diff --git a/program-tests/compressed-token-test/tests/ctoken/create_ata.rs b/program-tests/compressed-token-test/tests/ctoken/create_ata.rs index 2ea1b6c730..738cbc57fa 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create_ata.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create_ata.rs @@ -479,7 +479,38 @@ async fn test_create_ata_failing() { light_program_test::utils::assert::assert_rpc_error(result, 0, 20001).unwrap(); } - // Test 7: Wrong account type (correct program owner, wrong discriminator) + // Test 7: write_top_up exceeds max_top_up from RentConfig + // Accounts cannot be created with lamports_per_write > max_top_up. + // This protects against griefing attacks where recipient creates account with excessive top-up. + // Error: 18042 (WriteTopUpExceedsMaximum from CTokenError) + { + // Use different mint for this test + context.mint_pubkey = solana_sdk::pubkey::Pubkey::new_unique(); + + // Default max_top_up is 6208, so use 6209 to exceed it + let excessive_lamports_per_write = RentConfig::default().max_top_up as u32 + 1; + + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(excessive_lamports_per_write), + account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + + create_and_assert_ata_fails( + &mut context, + Some(compressible_data), + false, // Non-idempotent + "write_top_up_exceeds_max_top_up", + 18042, // WriteTopUpExceedsMaximum from CTokenError + ) + .await; + } + + // Test 8: Wrong account type (correct program owner, wrong discriminator) // Passing an account owned by the registry program but not a CompressibleConfig. // Using the protocol config account which has a different discriminator. // Error: 20000 (InvalidDiscriminator from account-checks) @@ -749,7 +780,8 @@ async fn test_create_ata_random() { } }, lamports_per_write: if rng.gen_bool(0.5) { - Some(rng.gen_range(0..=u16::MAX as u32)) + // Limit to max_top_up to avoid WriteTopUpExceedsMaximum error + Some(rng.gen_range(0..=RentConfig::default().max_top_up as u32)) } else { None }, diff --git a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs index 1be8af2686..cad123444d 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -3,7 +3,7 @@ use light_account_checks::AccountInfoTrait; use light_compressible::{compression_info::ZCompressionInfoMut, config::CompressibleConfig}; use light_ctoken_types::{ instructions::extensions::compressible::CompressibleExtensionInstructionData, - state::CompressionInfo, COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, + state::CompressionInfo, CTokenError, COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, }; use light_program_profiler::profile; use light_zero_copy::traits::ZeroCopyAtMut; @@ -153,6 +153,17 @@ fn configure_compressible_extension( compressible_extension.rent_sponsor = compressible_config_account.rent_sponsor.to_bytes(); } + // Validate write_top_up doesn't exceed max_top_up + if compressible_config.write_top_up as u64 + > compressible_config_account.rent_config.max_top_up as u64 + { + msg!( + "write_top_up {} exceeds max_top_up {}", + compressible_config.write_top_up, + compressible_config_account.rent_config.max_top_up + ); + return Err(CTokenError::WriteTopUpExceedsMaximum.into()); + } compressible_extension .lamports_per_write .set(compressible_config.write_top_up); From 8b53e05bc7de914e3b2cd3bc054c137f144434a3 Mon Sep 17 00:00:00 2001 From: ananas-block Date: Thu, 4 Dec 2025 17:15:21 +0000 Subject: [PATCH 2/4] test: max lamports for top up --- .../compressible/src/compression_info.rs | 1 + program-libs/ctoken-types/src/error.rs | 4 + .../src/instructions/mint_action/builder.rs | 9 + .../mint_action/instruction_data.rs | 2 + .../transfer2/instruction_data.rs | 2 + .../tests/ctoken/transfer.rs | 72 ++++++++ .../tests/mint/failing.rs | 164 +++++++++++++++++- .../tests/transfer2/compress_failing.rs | 129 ++++++++++++++ .../no_system_program_cpi_failing.rs | 1 + .../program/src/ctoken_transfer.rs | 47 ++++- .../src/mint_action/actions/mint_to_ctoken.rs | 3 +- .../mint_action/actions/process_actions.rs | 10 ++ .../ctoken/compress_or_decompress_ctokens.rs | 9 + .../src/transfer2/compression/ctoken/mod.rs | 3 +- .../program/src/transfer2/compression/mod.rs | 18 +- .../program/src/transfer2/processor.rs | 2 + .../compressed_token/compress_and_close.rs | 1 + .../v2/transfer2/instruction.rs | 9 + .../src/ctoken/transfer_ctoken.rs | 10 ++ .../src/ctoken/transfer_interface.rs | 2 + 20 files changed, 488 insertions(+), 10 deletions(-) diff --git a/program-libs/compressible/src/compression_info.rs b/program-libs/compressible/src/compression_info.rs index c217b5c6cd..1b44a71886 100644 --- a/program-libs/compressible/src/compression_info.rs +++ b/program-libs/compressible/src/compression_info.rs @@ -97,6 +97,7 @@ macro_rules! impl_is_compressible { rent_exemption_lamports: u64, ) -> Result { let lamports_per_write: u32 = self.lamports_per_write.into(); + // Calculate rent status using AccountRentState let state = crate::rent::AccountRentState { num_bytes, diff --git a/program-libs/ctoken-types/src/error.rs b/program-libs/ctoken-types/src/error.rs index fc78d5207a..1e88755f2d 100644 --- a/program-libs/ctoken-types/src/error.rs +++ b/program-libs/ctoken-types/src/error.rs @@ -132,6 +132,9 @@ pub enum CTokenError { #[error("write_top_up exceeds max_top_up from RentConfig")] WriteTopUpExceedsMaximum, + + #[error("Calculated top-up exceeds sender's max_top_up limit")] + MaxTopUpExceeded, } impl From for u32 { @@ -179,6 +182,7 @@ impl From for u32 { CTokenError::DuplicateMetadataKey => 18040, CTokenError::TooManySeeds(_) => 18041, CTokenError::WriteTopUpExceedsMaximum => 18042, + CTokenError::MaxTopUpExceeded => 18043, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-libs/ctoken-types/src/instructions/mint_action/builder.rs b/program-libs/ctoken-types/src/instructions/mint_action/builder.rs index 14944d6dd5..11bbd9d77a 100644 --- a/program-libs/ctoken-types/src/instructions/mint_action/builder.rs +++ b/program-libs/ctoken-types/src/instructions/mint_action/builder.rs @@ -33,6 +33,7 @@ impl MintActionCompressedInstructionData { compressed_address: mint_with_context.address, token_pool_bump: 0, token_pool_index: 0, + max_top_up: 0, // No limit by default create_mint: None, actions: Vec::new(), proof, @@ -54,6 +55,7 @@ impl MintActionCompressedInstructionData { compressed_address, token_pool_bump: 0, token_pool_index: 0, + max_top_up: 0, // No limit by default create_mint: Some(CreateMint::default()), actions: Vec::new(), proof: Some(proof), @@ -75,6 +77,7 @@ impl MintActionCompressedInstructionData { compressed_address, token_pool_bump: 0, token_pool_index: 0, + max_top_up: 0, // No limit by default create_mint: Some(CreateMint::default()), actions: Vec::new(), proof: None, // Proof is verified with execution not write @@ -131,6 +134,12 @@ impl MintActionCompressedInstructionData { self } + #[must_use = "with_max_top_up returns a new value"] + pub fn with_max_top_up(mut self, max_top_up: u16) -> Self { + self.max_top_up = max_top_up; + self + } + #[must_use = "write_to_cpi_context_first returns a new value"] pub fn write_to_cpi_context_first(mut self) -> Self { if let Some(ref mut ctx) = self.cpi_context { diff --git a/program-libs/ctoken-types/src/instructions/mint_action/instruction_data.rs b/program-libs/ctoken-types/src/instructions/mint_action/instruction_data.rs index b06d507aa5..30d45d67ad 100644 --- a/program-libs/ctoken-types/src/instructions/mint_action/instruction_data.rs +++ b/program-libs/ctoken-types/src/instructions/mint_action/instruction_data.rs @@ -57,6 +57,8 @@ pub struct MintActionCompressedInstructionData { /// Used to check token pool derivation. /// Only required if associated spl mint exists and actions contain mint actions. pub token_pool_index: u8, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + pub max_top_up: u16, pub create_mint: Option, pub actions: Vec, pub proof: Option, diff --git a/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs b/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs index f2cbad8217..342ce06be0 100644 --- a/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs +++ b/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs @@ -17,6 +17,8 @@ pub struct CompressedTokenInstructionDataTransfer2 { /// Placeholder currently unimplemented. pub lamports_change_account_owner_index: u8, pub output_queue: u8, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + pub max_top_up: u16, pub cpi_context: Option, pub compressions: Option>, pub proof: Option, diff --git a/program-tests/compressed-token-test/tests/ctoken/transfer.rs b/program-tests/compressed-token-test/tests/ctoken/transfer.rs index c90709525c..6e45a08f94 100644 --- a/program-tests/compressed-token-test/tests/ctoken/transfer.rs +++ b/program-tests/compressed-token-test/tests/ctoken/transfer.rs @@ -124,6 +124,35 @@ fn build_transfer_instruction( } } +/// Build a ctoken transfer instruction with max_top_up parameter +fn build_transfer_instruction_with_max_top_up( + source: Pubkey, + destination: Pubkey, + amount: u64, + authority: Pubkey, + max_top_up: u16, +) -> solana_sdk::instruction::Instruction { + use anchor_lang::prelude::AccountMeta; + use solana_sdk::instruction::Instruction; + + // Build instruction data: discriminator (3) + amount (8 bytes) + max_top_up (2 bytes) + let mut data = vec![3]; // CTokenTransfer discriminator + data.extend_from_slice(&amount.to_le_bytes()); // Amount as u64 little-endian + data.extend_from_slice(&max_top_up.to_le_bytes()); // max_top_up as u16 little-endian + + // Build instruction + Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(source, false), + AccountMeta::new(destination, false), + AccountMeta::new(authority, true), // Authority must sign (also acts as payer for top-ups) + AccountMeta::new_readonly(Pubkey::default(), false), // System program for lamport transfers during top-up + ], + data, + } +} + /// Execute a ctoken transfer and assert success async fn transfer_and_assert( context: &mut AccountTestContext, @@ -498,3 +527,46 @@ async fn test_ctoken_transfer_mixed_compressible_non_compressible() { ) .await; } + +// ============================================================================ +// max_top_up Tests +// ============================================================================ + +/// Test that ctoken_transfer fails when max_top_up is exceeded. +/// Creates compressible accounts with num_prepaid_epochs = 0 (no prepaid rent), +/// which requires rent top-up on every write. Setting max_top_up = 1 (too low) +/// should trigger MaxTopUpExceeded error (18043). +#[tokio::test] +async fn test_ctoken_transfer_max_top_up_exceeded() { + // Create compressible accounts with num_prepaid_epochs = 0 (needs top-up immediately) + let (mut context, source, destination, _mint_amount, _source_keypair, _dest_keypair) = + setup_transfer_test(Some(0), 1000).await.unwrap(); + + // Fund owner to pay for potential top-up + context + .rpc + .airdrop_lamports(&context.owner_keypair.pubkey(), 100_000_000) + .await + .unwrap(); + + let owner_keypair = context.owner_keypair.insecure_clone(); + let payer_pubkey = context.payer.pubkey(); + + // Build transfer instruction with max_top_up = 1 (too low to cover rent top-up) + let transfer_ix = build_transfer_instruction_with_max_top_up( + source, + destination, + 100, + owner_keypair.pubkey(), + 1, // max_top_up = 1 lamport (way too low for any rent top-up) + ); + + // Execute transfer expecting failure + let result = context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer_pubkey, &[&context.payer, &owner_keypair]) + .await; + + // Assert MaxTopUpExceeded (error code 18043) + light_program_test::utils::assert::assert_rpc_error(result, 0, 18043).unwrap(); +} diff --git a/program-tests/compressed-token-test/tests/mint/failing.rs b/program-tests/compressed-token-test/tests/mint/failing.rs index 92bd191a58..978b8abb70 100644 --- a/program-tests/compressed-token-test/tests/mint/failing.rs +++ b/program-tests/compressed-token-test/tests/mint/failing.rs @@ -19,7 +19,10 @@ use light_token_client::{ }; use serial_test::serial; use solana_sdk::{ - instruction::AccountMeta, signature::Keypair, signer::Signer, transaction::Transaction, + instruction::{AccountMeta, Instruction}, + signature::Keypair, + signer::Signer, + transaction::Transaction, }; /// Functional and Failing tests: @@ -807,6 +810,165 @@ async fn functional_and_failing_tests() { } } +/// Test that mint_action fails when max_top_up is exceeded during MintToCToken. +/// Creates a compressible CToken ATA with pre_pay_num_epochs = 0 (no prepaid rent), +/// which requires rent top-up on any mint write. Setting max_top_up = 1 (too low) +/// should trigger MaxTopUpExceeded error (18043). +#[tokio::test] +#[serial] +async fn test_mint_to_ctoken_max_top_up_exceeded() { + use light_compressed_account::instruction_data::traits::LightInstructionData; + use light_compressed_token_sdk::compressed_token::{ + create_compressed_mint::derive_compressed_mint_address, mint_action::MintActionMetaConfig, + }; + use light_ctoken_types::{ + instructions::mint_action::{ + CompressedMintWithContext, MintActionCompressedInstructionData, MintToCTokenAction, + }, + state::TokenDataVersion, + COMPRESSED_TOKEN_PROGRAM_ID, + }; + + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + + let payer = Keypair::new(); + rpc.airdrop_lamports(&payer.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let mint_seed = Keypair::new(); + let mint_authority = Keypair::new(); + rpc.airdrop_lamports(&mint_authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_compressed_mint_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, _) = find_spl_mint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint + light_token_client::actions::create_mint( + &mut rpc, + &mint_seed, + 8, // decimals + &mint_authority, + None, // no freeze authority + None, // no metadata + &payer, + ) + .await + .unwrap(); + + // 2. Create compressible CToken ATA with pre_pay_num_epochs = 0 (NO prepaid rent) + let recipient = Keypair::new(); + + let compressible_params = CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, // NO prepaid epochs - needs top-up immediately + lamports_per_write: Some(1000), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + }; + + let create_ata_ix = CreateAssociatedTokenAccount::new( + payer.pubkey(), + recipient.pubkey(), + spl_mint_pda, + compressible_params, + ) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + let ctoken_ata = + light_compressed_token_sdk::ctoken::derive_ctoken_ata(&recipient.pubkey(), &spl_mint_pda).0; + + // 3. Build MintToCToken instruction with max_top_up = 1 (too low) + // Get current compressed mint state + let compressed_mint_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + + let compressed_mint: light_ctoken_types::state::CompressedMint = BorshDeserialize::deserialize( + &mut compressed_mint_account.data.unwrap().data.as_slice(), + ) + .unwrap(); + + // Get validity proof + let rpc_proof_result = rpc + .get_validity_proof(vec![compressed_mint_account.hash], vec![], None) + .await + .unwrap() + .value; + + let compressed_mint_inputs = CompressedMintWithContext { + prove_by_index: rpc_proof_result.accounts[0].root_index.proof_by_index(), + leaf_index: compressed_mint_account.leaf_index, + root_index: rpc_proof_result.accounts[0] + .root_index + .root_index() + .unwrap_or_default(), + address: compressed_mint_address, + mint: compressed_mint.try_into().unwrap(), + }; + + // Build instruction data with max_top_up = 1 (too low to cover rent top-up) + let instruction_data = + MintActionCompressedInstructionData::new(compressed_mint_inputs, rpc_proof_result.proof.0) + .with_mint_to_ctoken(MintToCTokenAction { + account_index: 0, + amount: 1000u64, + }) + .with_max_top_up(1); // max_top_up = 1 lamport (way too low) + + // Build account metas + let config = MintActionMetaConfig::new( + payer.pubkey(), + mint_authority.pubkey(), + rpc_proof_result.accounts[0].tree_info.tree, + rpc_proof_result.accounts[0].tree_info.queue, + rpc_proof_result.accounts[0].tree_info.queue, + ) + .with_ctoken_accounts(vec![ctoken_ata]); + + let account_metas = config.to_account_metas(); + + // Serialize instruction data + let data = instruction_data.data().unwrap(); + + // Build final instruction + let ix = Instruction { + program_id: COMPRESSED_TOKEN_PROGRAM_ID.into(), + accounts: account_metas, + data, + }; + + // 4. Execute and expect MaxTopUpExceeded (18043) + let result = rpc + .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer, &mint_authority]) + .await; + + assert_rpc_error( + result, 0, 18043, // CTokenError::MaxTopUpExceeded = 18043 + ) + .unwrap(); +} + /// Test that mint_signer must be a signer when creating a compressed mint #[tokio::test] #[serial] diff --git a/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs b/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs index c2e287c7b7..9a36af6523 100644 --- a/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs @@ -585,3 +585,132 @@ async fn test_compression_recipient_out_of_bounds() -> Result<(), RpcError> { Ok(()) } + +/// Test that transfer2 compression fails when max_top_up is exceeded. +/// Creates a compressible CToken ATA with pre_pay_num_epochs = 0 (no prepaid rent), +/// which requires rent top-up on any compression write. Setting max_top_up = 1 (too low) +/// should trigger MaxTopUpExceeded error (18043). +#[tokio::test] +async fn test_compression_max_top_up_exceeded() -> Result<(), RpcError> { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)).await?; + let payer = rpc.get_payer().insecure_clone(); + + // Create owner and airdrop lamports + let owner = Keypair::new(); + rpc.airdrop_lamports(&owner.pubkey(), 1_000_000_000).await?; + + // Create mint authority + let mint_authority = Keypair::new(); + rpc.airdrop_lamports(&mint_authority.pubkey(), 1_000_000_000) + .await?; + + // Create compressed mint seed + let mint_seed = Keypair::new(); + + // Derive mint and ATA addresses + let (mint, _) = find_spl_mint_address(&mint_seed.pubkey()); + let (ctoken_ata, _) = derive_ctoken_ata(&owner.pubkey(), &mint); + + // Create compressible CToken ATA with pre_pay_num_epochs = 0 (NO prepaid rent) + // This means any write operation will require immediate rent top-up + let compressible_params = CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, // NO prepaid epochs - needs top-up immediately + lamports_per_write: Some(1000), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + }; + + let create_ata_instruction = CreateAssociatedTokenAccount::new( + payer.pubkey(), + owner.pubkey(), + mint, + compressible_params, + ) + .instruction() + .map_err(|e| RpcError::AssertRpcError(format!("Failed to create ATA: {:?}", e)))?; + + rpc.create_and_send_transaction(&[create_ata_instruction], &payer.pubkey(), &[&payer]) + .await?; + + // Create mint and mint tokens to decompressed CToken ATA + let token_amount = 1000u64; + let decompressed_recipients = vec![Recipient::new(owner.pubkey(), token_amount)]; + + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + vec![], // no compressed recipients + decompressed_recipients, // mint to decompressed CToken ATA + None, + None, + Some(light_token_client::instructions::mint_action::NewMint { + decimals: 6, + supply: 0, + mint_authority: mint_authority.pubkey(), + freeze_authority: None, + metadata: None, + version: 3, // ShaFlat for compressible accounts + }), + ) + .await?; + + // Get output queue for compression + let output_queue = rpc + .get_random_state_tree_info() + .unwrap() + .get_output_pubkey() + .unwrap(); + + // Warp forward to a new epoch so that lamports_per_write is charged + use light_program_test::program_test::TestRpc; + rpc.warp_to_slot(500_000)?; + + // Build compression Transfer2Inputs with max_top_up = 1 (way too low) + let mut packed_accounts = PackedAccounts::default(); + packed_accounts.insert_or_get(output_queue); + + let mint_index = packed_accounts.insert_or_get_read_only(mint); + let authority_index = packed_accounts.insert_or_get_config(owner.pubkey(), true, false); + let recipient_index = packed_accounts.insert_or_get_read_only(owner.pubkey()); + let ctoken_ata_index = packed_accounts.insert_or_get_config(ctoken_ata, false, true); + + let mut compression_account = CTokenAccount2::new_empty(recipient_index, mint_index); + compression_account + .compress_ctoken(token_amount, ctoken_ata_index, authority_index) + .map_err(|e| RpcError::AssertRpcError(format!("Failed to compress: {:?}", e)))?; + + let (account_metas, _, _) = packed_accounts.to_account_metas(); + + let compression_inputs = Transfer2Inputs { + token_accounts: vec![compression_account], + validity_proof: ValidityProof::default(), + transfer_config: Transfer2Config::default() + .filter_zero_amount_outputs() + .with_max_top_up(1), // max_top_up = 1 lamport (way too low) + meta_config: Transfer2AccountsMetaConfig::new(payer.pubkey(), account_metas), + in_lamports: None, + out_lamports: None, + output_queue: 0, + }; + + // Create instruction + let ix = create_transfer2_instruction(compression_inputs) + .map_err(|e| RpcError::AssertRpcError(format!("Failed to create instruction: {:?}", e)))?; + + // Send transaction + let result = rpc + .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer, &owner]) + .await; + + // Should fail with MaxTopUpExceeded (18043) + light_program_test::utils::assert::assert_rpc_error(result, 0, 18043).unwrap(); + + Ok(()) +} diff --git a/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs b/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs index e3ef7721ab..c501b105b6 100644 --- a/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs @@ -267,6 +267,7 @@ fn build_compressions_only_instruction( out_tlv: None, compressions, cpi_context: None, + max_top_up: 0, // No limit }; // Serialize instruction data diff --git a/programs/compressed-token/program/src/ctoken_transfer.rs b/programs/compressed-token/program/src/ctoken_transfer.rs index 2d0c819a75..5f4cf1c035 100644 --- a/programs/compressed-token/program/src/ctoken_transfer.rs +++ b/programs/compressed-token/program/src/ctoken_transfer.rs @@ -13,6 +13,10 @@ use crate::shared::{ }; /// Process ctoken transfer instruction +/// +/// Instruction data format (backwards compatible): +/// - 8 bytes: amount (legacy, no max_top_up enforcement) +/// - 10 bytes: amount + max_top_up (u16, 0 = no limit) #[profile] #[inline(always)] pub fn process_ctoken_transfer( @@ -27,16 +31,39 @@ pub fn process_ctoken_transfer( return Err(ProgramError::NotEnoughAccountKeys); } - process_transfer(accounts, instruction_data) + // Validate minimum instruction data length + if instruction_data.len() < 8 { + return Err(ProgramError::InvalidInstructionData); + } + + // Parse max_top_up based on instruction data length + // 0 means no limit + let max_top_up = match instruction_data.len() { + 8 => 0u16, // Legacy: no max_top_up + 10 => u16::from_le_bytes( + instruction_data[8..10] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + // Only pass the first 8 bytes (amount) to the SPL transfer processor + process_transfer(accounts, &instruction_data[..8]) .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; - calculate_and_execute_top_up_transfers(accounts) + calculate_and_execute_top_up_transfers(accounts, max_top_up) } /// Calculate and execute top-up transfers for compressible accounts +/// +/// # Arguments +/// * `accounts` - The account infos (source, dest, authority/payer) +/// * `max_top_up` - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) #[inline(always)] #[profile] fn calculate_and_execute_top_up_transfers( accounts: &[pinocchio::account_info::AccountInfo], + max_top_up: u16, ) -> Result<(), ProgramError> { // Initialize transfers array with account references, amounts will be updated let account0 = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; @@ -52,6 +79,9 @@ fn calculate_and_execute_top_up_transfers( }, ]; let mut current_slot = 0; + // Initialize budget: +1 allows exact match (total == max_top_up) + let mut lamports_budget = (max_top_up as u64).saturating_add(1); + // Calculate transfer amounts for accounts with compressible extensions for transfer in transfers.iter_mut() { if transfer.account.data_len() > light_ctoken_types::BASE_TOKEN_ACCOUNT_SIZE as usize { @@ -78,6 +108,8 @@ fn calculate_and_execute_top_up_transfers( light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, ) .map_err(|_| CTokenError::InvalidAccountData)?; + // Decrement budget + lamports_budget = lamports_budget.saturating_sub(transfer.amount); } } } else { @@ -93,9 +125,14 @@ fn calculate_and_execute_top_up_transfers( if transfers[0].amount == 0 && transfers[1].amount == 0 { return Ok(()); - } else { - let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; - multi_transfer_lamports(payer, &transfers).map_err(convert_program_error)?; } + + // Check budget wasn't exhausted (0 means exceeded max_top_up) + if max_top_up != 0 && lamports_budget == 0 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + + let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; + multi_transfer_lamports(payer, &transfers).map_err(convert_program_error)?; Ok(()) } 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 4e8db7fe62..b1e82b94de 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 @@ -22,6 +22,7 @@ pub fn process_mint_to_ctoken_action( packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, mint: Pubkey, transfer_amount: &mut u64, + lamports_budget: &mut u64, ) -> Result<(), ProgramError> { check_authority( compressed_mint.base.mint_authority, @@ -56,7 +57,7 @@ pub fn process_mint_to_ctoken_action( packed_accounts, ); - compress_or_decompress_ctokens(inputs, transfer_amount) + compress_or_decompress_ctokens(inputs, transfer_amount, lamports_budget) } #[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 38829ccebe..0db452b410 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 @@ -7,6 +7,7 @@ use light_ctoken_types::{ hash_cache::HashCache, instructions::mint_action::{ZAction, ZMintActionCompressedInstructionData}, state::CompressedMint, + CTokenError, }; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; @@ -46,6 +47,9 @@ pub fn process_actions<'a>( ) -> Result<(), ProgramError> { // Array to accumulate transfer amounts by account index let mut transfer_map = [0u64; MAX_PACKED_ACCOUNTS]; + // Initialize budget: +1 allows exact match (total == max_top_up) + let max_top_up: u16 = parsed_instruction_data.max_top_up.get(); + let mut lamports_budget = (max_top_up as u64).saturating_add(1); // Start metadata authority with same value as mint authority for action in parsed_instruction_data.actions.iter() { @@ -108,6 +112,7 @@ pub fn process_actions<'a>( packed_accounts, parsed_instruction_data.mint.metadata.mint, &mut transfer_map[account_index], + &mut lamports_budget, )?; } ZAction::UpdateMetadataField(update_metadata_action) => { @@ -155,6 +160,11 @@ pub fn process_actions<'a>( // Execute transfers if any exist if !transfers.is_empty() { + // Check budget wasn't exhausted (0 means exceeded max_top_up) + if max_top_up != 0 && lamports_budget == 0 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + let fee_payer = validated_accounts .executing .as_ref() 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..a2b5c86735 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 @@ -17,10 +17,14 @@ use super::{compress_and_close::process_compress_and_close, inputs::CTokenCompre use crate::shared::owner_validation::check_ctoken_owner; /// Perform compression/decompression on a ctoken account. +/// +/// # Arguments +/// * `lamports_budget` - Mutable budget to decrement when transfer amounts are calculated. #[profile] pub fn compress_or_decompress_ctokens( inputs: CTokenCompressionInputs, transfer_amount: &mut u64, + lamports_budget: &mut u64, ) -> Result<(), ProgramError> { let CTokenCompressionInputs { authority, @@ -80,6 +84,7 @@ pub fn compress_or_decompress_ctokens( token_account_info, &mut current_slot, transfer_amount, + lamports_budget, ) } ZCompressionMode::Decompress => { @@ -95,6 +100,7 @@ pub fn compress_or_decompress_ctokens( token_account_info, &mut current_slot, transfer_amount, + lamports_budget, ) } ZCompressionMode::CompressAndClose => process_compress_and_close( @@ -114,6 +120,7 @@ fn process_compressible_extension( token_account_info: &AccountInfo, current_slot: &mut u64, transfer_amount: &mut u64, + lamports_budget: &mut u64, ) -> Result<(), ProgramError> { if *transfer_amount != 0 { return Ok(()); @@ -135,6 +142,8 @@ fn process_compressible_extension( light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, ) .map_err(|_| CTokenError::InvalidAccountData)?; + // Decrement budget + *lamports_budget = lamports_budget.saturating_sub(*transfer_amount); return Ok(()); } diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs index 17445ac851..3bf64a8cc3 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs +++ b/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs @@ -23,6 +23,7 @@ pub(super) fn process_ctoken_compressions( token_account_info: &AccountInfo, packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, transfer_amount: &mut u64, + lamports_budget: &mut u64, ) -> Result<(), anchor_lang::prelude::ProgramError> { // Validate compression fields for the given mode validate_compression_mode_fields(compression)?; @@ -35,5 +36,5 @@ pub(super) fn process_ctoken_compressions( packed_accounts, )?; - compress_or_decompress_ctokens(compression_inputs, transfer_amount) + compress_or_decompress_ctokens(compression_inputs, transfer_amount, lamports_budget) } diff --git a/programs/compressed-token/program/src/transfer2/compression/mod.rs b/programs/compressed-token/program/src/transfer2/compression/mod.rs index a79734e64c..ba91a46b13 100644 --- a/programs/compressed-token/program/src/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/transfer2/compression/mod.rs @@ -3,8 +3,11 @@ use anchor_lang::prelude::ProgramError; use arrayvec::ArrayVec; use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_compressed_account::pubkey::AsPubkey; -use light_ctoken_types::instructions::transfer2::{ - ZCompressedTokenInstructionDataTransfer2, ZCompression, ZCompressionMode, +use light_ctoken_types::{ + instructions::transfer2::{ + ZCompressedTokenInstructionDataTransfer2, ZCompression, ZCompressionMode, + }, + CTokenError, }; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; @@ -30,15 +33,21 @@ const SPL_TOKEN_2022_ID: &[u8; 32] = &spl_token_2022::ID.to_bytes(); const ID: &[u8; 32] = &LIGHT_CPI_SIGNER.program_id; /// Process native compressions/decompressions with token accounts +/// +/// # Arguments +/// * `max_top_up` - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) #[profile] pub fn process_token_compression( fee_payer: &AccountInfo, inputs: &ZCompressedTokenInstructionDataTransfer2, packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, cpi_authority: &AccountInfo, + max_top_up: u16, ) -> Result<(), ProgramError> { if let Some(compressions) = inputs.compressions.as_ref() { let mut transfer_map = [0u64; MAX_PACKED_ACCOUNTS]; + // Initialize budget: +1 allows exact match (total == max_top_up) + let mut lamports_budget = (max_top_up as u64).saturating_add(1); for compression in compressions { let account_index = compression.source_or_recipient as usize; @@ -63,6 +72,7 @@ pub fn process_token_compression( source_or_recipient, packed_accounts, &mut transfer_map[account_index], + &mut lamports_budget, )?, SPL_TOKEN_ID => { spl::process_spl_compressions( @@ -115,6 +125,10 @@ pub fn process_token_compression( .collect::, ProgramError>>()?; if !transfers.is_empty() { + // Check budget wasn't exhausted (0 means exceeded max_top_up) + if max_top_up != 0 && lamports_budget == 0 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } multi_transfer_lamports(fee_payer, &transfers).map_err(convert_program_error)? } } diff --git a/programs/compressed-token/program/src/transfer2/processor.rs b/programs/compressed-token/program/src/transfer2/processor.rs index d64e70f798..fe0f846ced 100644 --- a/programs/compressed-token/program/src/transfer2/processor.rs +++ b/programs/compressed-token/program/src/transfer2/processor.rs @@ -139,6 +139,7 @@ fn process_no_system_program_cpi( inputs, &validated_accounts.packed_accounts, cpi_authority_pda, + inputs.max_top_up.get(), )?; close_for_compress_and_close(compressions.as_slice(), validated_accounts)?; @@ -208,6 +209,7 @@ fn process_with_system_program_cpi( inputs, &validated_accounts.packed_accounts, system_accounts.cpi_authority_pda, + inputs.max_top_up.get(), )?; // Get CPI accounts slice and tree accounts for light-system-program invocation diff --git a/programs/registry/src/compressible/compressed_token/compress_and_close.rs b/programs/registry/src/compressible/compressed_token/compress_and_close.rs index 4f1e43a55f..d0489a7cb8 100644 --- a/programs/registry/src/compressible/compressed_token/compress_and_close.rs +++ b/programs/registry/src/compressible/compressed_token/compress_and_close.rs @@ -136,6 +136,7 @@ pub fn compress_and_close_ctoken_accounts_with_indices<'info>( out_tlv: None, compressions: Some(compressions), cpi_context: None, + max_top_up: 0, }; // Serialize instruction data diff --git a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs index dfc7e884df..4cf5cebcef 100644 --- a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs +++ b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs @@ -21,6 +21,8 @@ pub struct Transfer2Config { pub sol_pool_pda: bool, pub sol_decompression_recipient: Option, pub filter_zero_amount_outputs: bool, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + pub max_top_up: u16, } impl Transfer2Config { @@ -48,6 +50,12 @@ impl Transfer2Config { self.filter_zero_amount_outputs = true; self } + + /// Set maximum per-account top-up lamports (0 = no limit) + pub fn with_max_top_up(mut self, max_top_up: u16) -> Self { + self.max_top_up = max_top_up; + self + } } /// Multi-transfer input parameters @@ -116,6 +124,7 @@ pub fn create_transfer2_instruction(inputs: Transfer2Inputs) -> Result, } pub struct TransferCtokenAccountInfos<'info> { @@ -17,6 +20,8 @@ pub struct TransferCtokenAccountInfos<'info> { pub destination: AccountInfo<'info>, pub amount: u64, pub authority: AccountInfo<'info>, + /// Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + pub max_top_up: Option, } impl<'info> TransferCtokenAccountInfos<'info> { @@ -44,6 +49,7 @@ impl<'info> From<&TransferCtokenAccountInfos<'info>> for TransferCtoken { destination: *account_infos.destination.key, amount: account_infos.amount, authority: *account_infos.authority.key, + max_top_up: account_infos.max_top_up, } } } @@ -60,6 +66,10 @@ impl TransferCtoken { data: { let mut data = vec![3u8]; data.extend_from_slice(&self.amount.to_le_bytes()); + // Include max_top_up if set (10-byte format) + if let Some(max_top_up) = self.max_top_up { + data.extend_from_slice(&max_top_up.to_le_bytes()); + } data }, }) diff --git a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs index 037bbee291..bd63682da5 100644 --- a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs +++ b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs @@ -102,6 +102,7 @@ impl<'info> TransferInterface<'info> { destination: self.destination_account.clone(), amount: self.amount, authority: self.authority.clone(), + max_top_up: None, // No limit by default } .invoke(), @@ -171,6 +172,7 @@ impl<'info> TransferInterface<'info> { destination: self.destination_account.clone(), amount: self.amount, authority: self.authority.clone(), + max_top_up: None, // No limit by default } .invoke_signed(signer_seeds), From 4d3a6a5f1592a2d3f2f53fe481d7087a574d2ffa Mon Sep 17 00:00:00 2001 From: ananas-block Date: Thu, 4 Dec 2025 17:29:23 +0000 Subject: [PATCH 3/4] cleanup --- .github/workflows/programs.yml | 2 +- .../compressed-token-test/tests/ctoken/transfer.rs | 6 +++++- .../compressed-token-test/tests/mint/failing.rs | 7 +++---- .../compressed-token/program/src/ctoken_transfer.rs | 2 +- .../program/src/shared/initialize_ctoken_account.rs | 3 +-- .../ctoken/compress_or_decompress_ctokens.rs | 2 +- programs/compressed-token/program/tests/mint.rs | 1 + .../compressed-token/program/tests/mint_action.rs | 13 ++++++------- sdk-tests/sdk-ctoken-test/src/transfer.rs | 2 ++ 9 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/programs.yml b/.github/workflows/programs.yml index a6b5239c09..9d60bbfb82 100644 --- a/.github/workflows/programs.yml +++ b/.github/workflows/programs.yml @@ -60,7 +60,7 @@ jobs: - program: light-system-program-compression sub-tests: '["cargo-test-sbf -p system-test -- test_with_compression", "cargo-test-sbf -p system-test --test test_re_init_cpi_account"]' - program: compressed-token-and-e2e - sub-tests: '["cargo-test-sbf -p compressed-token-test --test v1", "cargo-test-sbf -p compressed-token-test --test mint"]' + sub-tests: '["cargo test -p light-compressed-token", "cargo-test-sbf -p compressed-token-test --test v1", "cargo-test-sbf -p compressed-token-test --test mint"]' - program: compressed-token-batched-tree sub-tests: '["cargo-test-sbf -p compressed-token-test -- test_transfer_with_photon_and_batched_tree"]' - program: system-cpi-test diff --git a/program-tests/compressed-token-test/tests/ctoken/transfer.rs b/program-tests/compressed-token-test/tests/ctoken/transfer.rs index 6e45a08f94..9d179e8094 100644 --- a/program-tests/compressed-token-test/tests/ctoken/transfer.rs +++ b/program-tests/compressed-token-test/tests/ctoken/transfer.rs @@ -564,7 +564,11 @@ async fn test_ctoken_transfer_max_top_up_exceeded() { // Execute transfer expecting failure let result = context .rpc - .create_and_send_transaction(&[transfer_ix], &payer_pubkey, &[&context.payer, &owner_keypair]) + .create_and_send_transaction( + &[transfer_ix], + &payer_pubkey, + &[&context.payer, &owner_keypair], + ) .await; // Assert MaxTopUpExceeded (error code 18043) diff --git a/program-tests/compressed-token-test/tests/mint/failing.rs b/program-tests/compressed-token-test/tests/mint/failing.rs index 978b8abb70..2e80cee9a7 100644 --- a/program-tests/compressed-token-test/tests/mint/failing.rs +++ b/program-tests/compressed-token-test/tests/mint/failing.rs @@ -904,10 +904,9 @@ async fn test_mint_to_ctoken_max_top_up_exceeded() { .value .unwrap(); - let compressed_mint: light_ctoken_types::state::CompressedMint = BorshDeserialize::deserialize( - &mut compressed_mint_account.data.unwrap().data.as_slice(), - ) - .unwrap(); + let compressed_mint: light_ctoken_types::state::CompressedMint = + BorshDeserialize::deserialize(&mut compressed_mint_account.data.unwrap().data.as_slice()) + .unwrap(); // Get validity proof let rpc_proof_result = rpc diff --git a/programs/compressed-token/program/src/ctoken_transfer.rs b/programs/compressed-token/program/src/ctoken_transfer.rs index 5f4cf1c035..6fdc489fba 100644 --- a/programs/compressed-token/program/src/ctoken_transfer.rs +++ b/programs/compressed-token/program/src/ctoken_transfer.rs @@ -108,7 +108,7 @@ fn calculate_and_execute_top_up_transfers( light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, ) .map_err(|_| CTokenError::InvalidAccountData)?; - // Decrement budget + lamports_budget = lamports_budget.saturating_sub(transfer.amount); } } diff --git a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs index cad123444d..74497acf9c 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -154,8 +154,7 @@ fn configure_compressible_extension( } // Validate write_top_up doesn't exceed max_top_up - if compressible_config.write_top_up as u64 - > compressible_config_account.rent_config.max_top_up as u64 + if compressible_config.write_top_up > compressible_config_account.rent_config.max_top_up as u32 { msg!( "write_top_up {} exceeds max_top_up {}", 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 a2b5c86735..434c1a1f66 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 @@ -142,7 +142,7 @@ fn process_compressible_extension( light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, ) .map_err(|_| CTokenError::InvalidAccountData)?; - // Decrement budget + *lamports_budget = lamports_budget.saturating_sub(*transfer_amount); return Ok(()); diff --git a/programs/compressed-token/program/tests/mint.rs b/programs/compressed-token/program/tests/mint.rs index 875b8f41b3..35b5a46c7f 100644 --- a/programs/compressed-token/program/tests/mint.rs +++ b/programs/compressed-token/program/tests/mint.rs @@ -137,6 +137,7 @@ fn test_rnd_create_compressed_mint_account() { actions: vec![], // No actions for basic test proof: None, cpi_context: None, + max_top_up: 0, }; // Step 4: Serialize instruction data to test zero-copy diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index 8d99ccf2db..ae19c68487 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -185,6 +185,7 @@ fn generate_random_instruction_data( compressed_address: rng.gen::<[u8; 32]>(), token_pool_bump: rng.gen::(), token_pool_index: rng.gen::(), + max_top_up: rng.gen::(), actions, proof: if rng.gen_bool(0.6) { Some(random_compressed_proof(rng)) @@ -223,13 +224,11 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun .map(|ctx| ctx.first_set_context || ctx.set_context) .unwrap_or(false); - // 3. has_mint_to_actions - let has_mint_to_actions = data.actions.iter().any(|action| { - matches!( - action, - Action::MintToCompressed(_) | Action::MintToCToken(_) - ) - }); + // 3. has_mint_to_actions (only MintToCompressed needs tokens_out_queue, not MintToCToken) + let has_mint_to_actions = data + .actions + .iter() + .any(|action| matches!(action, Action::MintToCompressed(_))); // 4. create_spl_mint let create_spl_mint = data diff --git a/sdk-tests/sdk-ctoken-test/src/transfer.rs b/sdk-tests/sdk-ctoken-test/src/transfer.rs index d31f2fa5c2..88d2648213 100644 --- a/sdk-tests/sdk-ctoken-test/src/transfer.rs +++ b/sdk-tests/sdk-ctoken-test/src/transfer.rs @@ -34,6 +34,7 @@ pub fn process_transfer_invoke( destination: accounts[1].clone(), amount: data.amount, authority: accounts[2].clone(), + max_top_up: None, } .invoke()?; @@ -73,6 +74,7 @@ pub fn process_transfer_invoke_signed( destination: accounts[1].clone(), amount: data.amount, authority: accounts[2].clone(), + max_top_up: None, }; // Invoke with PDA signing - the builder handles instruction creation and invoke_signed CPI From dcb50140c3c2d364753a3d52cbb033cd3e145127 Mon Sep 17 00:00:00 2001 From: ananas-block Date: Thu, 4 Dec 2025 17:55:24 +0000 Subject: [PATCH 4/4] regenerate accounts --- ...config_pda_ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/accounts/compressible_config_pda_ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg.json b/cli/accounts/compressible_config_pda_ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg.json index bed204ae57..1d91856eca 100644 --- a/cli/accounts/compressible_config_pda_ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg.json +++ b/cli/accounts/compressible_config_pda_ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg.json @@ -1 +1 @@ -{"pubkey":"ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg","account":{"lamports":3048480,"data":["tATnGtyQN6gBAAH+Qgg4HTZ/YaoZoKlk0c8isySwNcDU3JXoIxQdOcQ0pr1CCDgdNn9hqhmgqWTRzyKzJLA1wNTclegjFB05xDSmvQyNmy4X80UX3keywHfcTHRpoDkggeuLDEJp3K8hdmjDNfsX+1mDkDWCPnO7jhPNTQ0DDtupetonCWjl6mDSxmj//4AA+CoBAgAACKbpmLBf6SuYoNn3ednb6LLz7vZ4Eyh766UmSYCRaL0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==","base64"],"owner":"Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX","executable":false,"rentEpoch":0,"space":310}} \ No newline at end of file +{"pubkey":"ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg","account":{"lamports":3048480,"data":["tATnGtyQN6gBAAH+SAQtJ+UbyIx39TonBXNob6aj8IhJZXQDWxu0dUrRKtBIBC0n5RvIjHf1OicFc2hvpqPwiElldANbG7R1StEq0AyNmy4X80UX3keywHfcTHRpoDkggeuLDEJp3K8hdmjDNfsX+1mDkDWCPnO7jhPNTQ0DDtupetonCWjl6mDSxmj//4AA+CoBAoAwCKbpmLBf6SuYoNn3ednb6LLz7vZ4Eyh766UmSYCRaL0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==","base64"],"owner":"Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX","executable":false,"rentEpoch":0,"space":310}} \ No newline at end of file