From 4158fb7b7c6b1c7725562c202f2b9a1ef5bb821f Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 5 Jan 2026 20:17:11 +0000 Subject: [PATCH 01/38] refactor: compressed token program folder structure into light_token, compressed_token --- .../mint_action/accounts.rs | 0 .../mint_action/actions/authority.rs | 0 .../actions/compress_and_close_cmint.rs | 2 +- .../mint_action/actions/create_mint.rs | 0 .../mint_action/actions/decompress_mint.rs | 4 +- .../mint_action/actions/mint_to.rs | 2 +- .../mint_action/actions/mint_to_ctoken.rs | 4 +- .../mint_action/actions/mod.rs | 0 .../mint_action/actions/process_actions.rs | 2 +- .../mint_action/actions/update_metadata.rs | 2 +- .../mint_action/mint_input.rs | 2 +- .../mint_action/mint_output.rs | 4 +- .../{ => compressed_token}/mint_action/mod.rs | 0 .../mint_action/processor.rs | 2 +- .../mint_action/queue_indices.rs | 0 .../mint_action/zero_copy_config.rs | 2 +- .../program/src/compressed_token/mod.rs | 2 + .../transfer2/accounts.rs | 2 +- .../transfer2/change_account.rs | 2 +- .../transfer2/check_extensions.rs | 0 .../compression/ctoken/compress_and_close.rs | 6 +-- .../ctoken/compress_or_decompress_ctokens.rs | 0 .../compression/ctoken/decompress.rs | 0 .../transfer2/compression/ctoken/inputs.rs | 0 .../transfer2/compression/ctoken/mod.rs | 0 .../transfer2/compression/mod.rs | 0 .../transfer2/compression/spl.rs | 0 .../transfer2/config.rs | 0 .../{ => compressed_token}/transfer2/cpi.rs | 0 .../{ => compressed_token}/transfer2/mod.rs | 0 .../transfer2/processor.rs | 4 +- .../transfer2/sum_check.rs | 0 .../transfer2/token_inputs.rs | 0 .../transfer2/token_outputs.rs | 0 .../program/src/{ => compressible}/claim.rs | 2 +- .../program/src/compressible/mod.rs | 5 +++ .../withdraw_funding_pool.rs | 2 +- programs/compressed-token/program/src/lib.rs | 41 +++++++------------ .../close_token_account/accounts.rs | 0 .../close_token_account/mod.rs | 0 .../close_token_account/processor.rs | 0 .../create_associated_token_account.rs | 2 +- .../{ => light_token}/create_token_account.rs | 0 .../ctoken_approve_revoke.rs | 2 +- .../src/{ => light_token}/ctoken_burn.rs | 0 .../{ => light_token}/ctoken_freeze_thaw.rs | 0 .../src/{ => light_token}/ctoken_mint_to.rs | 0 .../program/src/light_token/mod.rs | 21 ++++++++++ .../src/{ => light_token}/transfer/checked.rs | 0 .../src/{ => light_token}/transfer/default.rs | 2 +- .../src/{ => light_token}/transfer/mod.rs | 0 .../src/{ => light_token}/transfer/shared.rs | 0 .../program/src/shared/token_input.rs | 2 +- 53 files changed, 68 insertions(+), 53 deletions(-) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/accounts.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/authority.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/compress_and_close_cmint.rs (98%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/create_mint.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/decompress_mint.rs (98%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/mint_to.rs (97%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/mint_to_ctoken.rs (90%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/mod.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/process_actions.rs (99%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/actions/update_metadata.rs (98%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/mint_input.rs (95%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/mint_output.rs (99%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/mod.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/processor.rs (99%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/queue_indices.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/mint_action/zero_copy_config.rs (98%) create mode 100644 programs/compressed-token/program/src/compressed_token/mod.rs rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/accounts.rs (99%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/change_account.rs (98%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/check_extensions.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/compression/ctoken/compress_and_close.rs (98%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/compression/ctoken/decompress.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/compression/ctoken/inputs.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/compression/ctoken/mod.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/compression/mod.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/compression/spl.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/config.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/cpi.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/mod.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/processor.rs (99%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/sum_check.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/token_inputs.rs (100%) rename programs/compressed-token/program/src/{ => compressed_token}/transfer2/token_outputs.rs (100%) rename programs/compressed-token/program/src/{ => compressible}/claim.rs (98%) create mode 100644 programs/compressed-token/program/src/compressible/mod.rs rename programs/compressed-token/program/src/{ => compressible}/withdraw_funding_pool.rs (98%) rename programs/compressed-token/program/src/{ => light_token}/close_token_account/accounts.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/close_token_account/mod.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/close_token_account/processor.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/create_associated_token_account.rs (99%) rename programs/compressed-token/program/src/{ => light_token}/create_token_account.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/ctoken_approve_revoke.rs (99%) rename programs/compressed-token/program/src/{ => light_token}/ctoken_burn.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/ctoken_freeze_thaw.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/ctoken_mint_to.rs (100%) create mode 100644 programs/compressed-token/program/src/light_token/mod.rs rename programs/compressed-token/program/src/{ => light_token}/transfer/checked.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/transfer/default.rs (97%) rename programs/compressed-token/program/src/{ => light_token}/transfer/mod.rs (100%) rename programs/compressed-token/program/src/{ => light_token}/transfer/shared.rs (100%) diff --git a/programs/compressed-token/program/src/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/accounts.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs diff --git a/programs/compressed-token/program/src/mint_action/actions/authority.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/authority.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/actions/authority.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/authority.rs diff --git a/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs similarity index 98% rename from programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs index 4434953a1c..9ea9f77194 100644 --- a/programs/compressed-token/program/src/mint_action/actions/compress_and_close_cmint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs @@ -11,7 +11,7 @@ use pinocchio::{ use spl_pod::solana_msg::msg; use crate::{ - mint_action::accounts::MintActionAccounts, + compressed_token::mint_action::accounts::MintActionAccounts, shared::{convert_program_error, transfer_lamports::transfer_lamports}, }; diff --git a/programs/compressed-token/program/src/mint_action/actions/create_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/actions/create_mint.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs diff --git a/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs similarity index 98% rename from programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index b84bd1c955..c157558ceb 100644 --- a/programs/compressed-token/program/src/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -14,8 +14,8 @@ use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; use crate::{ - create_token_account::parse_config_account, - mint_action::accounts::MintActionAccounts, + compressed_token::mint_action::accounts::MintActionAccounts, + light_token::create_token_account::parse_config_account, shared::{ convert_program_error, create_pda_account::{create_pda_account, verify_pda}, diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs similarity index 97% rename from programs/compressed-token/program/src/mint_action/actions/mint_to.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs index dc538f4f9b..9ec58c163c 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs @@ -9,7 +9,7 @@ use light_program_profiler::profile; use light_sdk_pinocchio::instruction::ZOutputCompressedAccountWithPackedContextMut; use crate::{ - mint_action::{accounts::MintActionAccounts, check_authority}, + compressed_token::mint_action::{accounts::MintActionAccounts, check_authority}, shared::token_output::set_output_compressed_account, }; diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs similarity index 90% rename from programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs index 91001d5f4b..0945085351 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to_ctoken.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs @@ -9,8 +9,8 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use crate::{ - mint_action::{accounts::MintActionAccounts, check_authority}, - transfer2::compression::{compress_or_decompress_ctokens, CTokenCompressionInputs}, + compressed_token::mint_action::{accounts::MintActionAccounts, check_authority}, + compressed_token::transfer2::compression::{compress_or_decompress_ctokens, CTokenCompressionInputs}, }; #[allow(clippy::too_many_arguments)] diff --git a/programs/compressed-token/program/src/mint_action/actions/mod.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mod.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/actions/mod.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/mod.rs diff --git a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs similarity index 99% rename from programs/compressed-token/program/src/mint_action/actions/process_actions.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs index 38a9bd0b72..a4c91f0285 100644 --- a/programs/compressed-token/program/src/mint_action/actions/process_actions.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs @@ -14,7 +14,7 @@ use pinocchio::account_info::AccountInfo; use spl_pod::solana_msg::msg; use crate::{ - mint_action::{ + compressed_token::mint_action::{ accounts::MintActionAccounts, check_authority, compress_and_close_cmint::process_compress_and_close_cmint_action, diff --git a/programs/compressed-token/program/src/mint_action/actions/update_metadata.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs similarity index 98% rename from programs/compressed-token/program/src/mint_action/actions/update_metadata.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs index 5048ee7bdc..d15bd9d840 100644 --- a/programs/compressed-token/program/src/mint_action/actions/update_metadata.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs @@ -10,7 +10,7 @@ use light_ctoken_interface::{ use light_program_profiler::profile; use spl_pod::solana_msg::msg; -use crate::mint_action::check_authority; +use crate::compressed_token::mint_action::check_authority; /// Get mutable reference to metadata extension at specified index #[profile] diff --git a/programs/compressed-token/program/src/mint_action/mint_input.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs similarity index 95% rename from programs/compressed-token/program/src/mint_action/mint_input.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs index ef4c8ff306..a9fd961722 100644 --- a/programs/compressed-token/program/src/mint_action/mint_input.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs @@ -8,7 +8,7 @@ use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; use light_sdk::instruction::PackedMerkleContext; -use crate::{constants::COMPRESSED_MINT_DISCRIMINATOR, mint_action::accounts::AccountsConfig}; +use crate::{compressed_token::mint_action::accounts::AccountsConfig, constants::COMPRESSED_MINT_DISCRIMINATOR}; /// Creates and validates an input compressed mint account. /// This function follows the same pattern as create_output_compressed_mint_account diff --git a/programs/compressed-token/program/src/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs similarity index 99% rename from programs/compressed-token/program/src/mint_action/mint_output.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index d8cb7656d2..a1e227799b 100644 --- a/programs/compressed-token/program/src/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -13,12 +13,12 @@ use pinocchio::sysvars::{clock::Clock, rent::Rent, Sysvar}; use spl_pod::solana_msg::msg; use crate::{ - constants::COMPRESSED_MINT_DISCRIMINATOR, - mint_action::{ + compressed_token::mint_action::{ accounts::{AccountsConfig, MintActionAccounts}, actions::process_actions, queue_indices::QueueIndices, }, + constants::COMPRESSED_MINT_DISCRIMINATOR, shared::{convert_program_error, transfer_lamports::transfer_lamports}, }; diff --git a/programs/compressed-token/program/src/mint_action/mod.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mod.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/mod.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/mod.rs diff --git a/programs/compressed-token/program/src/mint_action/processor.rs b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs similarity index 99% rename from programs/compressed-token/program/src/mint_action/processor.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/processor.rs index 14ba3ec9dd..dfcbf64646 100644 --- a/programs/compressed-token/program/src/mint_action/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs @@ -10,7 +10,7 @@ use light_zero_copy::{traits::ZeroCopyAt, ZeroCopyNew}; use pinocchio::account_info::AccountInfo; use crate::{ - mint_action::{ + compressed_token::mint_action::{ accounts::{AccountsConfig, MintActionAccounts}, create_mint::process_create_mint_action, mint_input::create_input_compressed_mint_account, diff --git a/programs/compressed-token/program/src/mint_action/queue_indices.rs b/programs/compressed-token/program/src/compressed_token/mint_action/queue_indices.rs similarity index 100% rename from programs/compressed-token/program/src/mint_action/queue_indices.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/queue_indices.rs diff --git a/programs/compressed-token/program/src/mint_action/zero_copy_config.rs b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs similarity index 98% rename from programs/compressed-token/program/src/mint_action/zero_copy_config.rs rename to programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs index 6bd87282af..5beba029ac 100644 --- a/programs/compressed-token/program/src/mint_action/zero_copy_config.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs @@ -10,7 +10,7 @@ use spl_pod::solana_msg::msg; use tinyvec::ArrayVec; use crate::{ - mint_action::accounts::AccountsConfig, + compressed_token::mint_action::accounts::AccountsConfig, shared::{ convert_program_error, cpi_bytes_size::{ diff --git a/programs/compressed-token/program/src/compressed_token/mod.rs b/programs/compressed-token/program/src/compressed_token/mod.rs new file mode 100644 index 0000000000..ebc41da8c3 --- /dev/null +++ b/programs/compressed-token/program/src/compressed_token/mod.rs @@ -0,0 +1,2 @@ +pub mod mint_action; +pub mod transfer2; diff --git a/programs/compressed-token/program/src/transfer2/accounts.rs b/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs similarity index 99% rename from programs/compressed-token/program/src/transfer2/accounts.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs index 5b47c229df..7a46e5bf5b 100644 --- a/programs/compressed-token/program/src/transfer2/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs @@ -6,11 +6,11 @@ use pinocchio::{account_info::AccountInfo, pubkey::Pubkey}; use spl_pod::solana_msg::msg; use crate::{ + compressed_token::transfer2::config::Transfer2Config, shared::{ accounts::{CpiContextLightSystemAccounts, LightSystemAccounts}, AccountIterator, }, - transfer2::config::Transfer2Config, }; /// 3 Scenarios: diff --git a/programs/compressed-token/program/src/transfer2/change_account.rs b/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs similarity index 98% rename from programs/compressed-token/program/src/transfer2/change_account.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs index 4c5bb6fdd4..3b35522c4d 100644 --- a/programs/compressed-token/program/src/transfer2/change_account.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs @@ -6,7 +6,7 @@ use light_compressed_account::instruction_data::with_readonly::ZInstructionDataI use light_ctoken_interface::instructions::transfer2::ZCompressedTokenInstructionDataTransfer2; use pinocchio::account_info::AccountInfo; -use crate::transfer2::config::Transfer2Config; +use crate::compressed_token::transfer2::config::Transfer2Config; /// Create a change account for excess lamports (following anchor program pattern) pub fn assign_change_account( diff --git a/programs/compressed-token/program/src/transfer2/check_extensions.rs b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/check_extensions.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs similarity index 98% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 62575eed0b..1c046dd6d3 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -19,10 +19,10 @@ use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; use crate::{ - close_token_account::{ + compressed_token::transfer2::accounts::Transfer2Accounts, + light_token::close_token_account::{ accounts::CloseTokenAccountAccounts, processor::validate_token_account_for_close_transfer2, }, - transfer2::accounts::Transfer2Accounts, }; /// Process compress and close operation for a ctoken account. @@ -335,7 +335,7 @@ pub fn close_for_compress_and_close( let authority = validated_accounts .packed_accounts .get_u8(compression.authority, "CompressAndClose: authority")?; - use crate::close_token_account::processor::close_token_account; + use crate::light_token::close_token_account::processor::close_token_account; close_token_account(&CloseTokenAccountAccounts { token_account: token_account_info, destination, diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/decompress.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs diff --git a/programs/compressed-token/program/src/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/compression/mod.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs diff --git a/programs/compressed-token/program/src/transfer2/compression/spl.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/compression/spl.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs diff --git a/programs/compressed-token/program/src/transfer2/config.rs b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/config.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/config.rs diff --git a/programs/compressed-token/program/src/transfer2/cpi.rs b/programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/cpi.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs diff --git a/programs/compressed-token/program/src/transfer2/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/mod.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/mod.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/mod.rs diff --git a/programs/compressed-token/program/src/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs similarity index 99% rename from programs/compressed-token/program/src/transfer2/processor.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 18f49e9fe7..20fd0c0875 100644 --- a/programs/compressed-token/program/src/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -20,8 +20,7 @@ use spl_pod::solana_msg::msg; use super::check_extensions::{build_mint_extension_cache, MintExtensionCache}; use crate::{ - shared::{convert_program_error, cpi::execute_cpi_invoke}, - transfer2::{ + compressed_token::transfer2::{ accounts::Transfer2Accounts, compression::{close_for_compress_and_close, process_token_compression}, config::Transfer2Config, @@ -30,6 +29,7 @@ use crate::{ token_inputs::set_input_compressed_accounts, token_outputs::set_output_compressed_accounts, }, + shared::{convert_program_error, cpi::execute_cpi_invoke}, }; /// Process a token transfer instruction diff --git a/programs/compressed-token/program/src/transfer2/sum_check.rs b/programs/compressed-token/program/src/compressed_token/transfer2/sum_check.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/sum_check.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/sum_check.rs diff --git a/programs/compressed-token/program/src/transfer2/token_inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/token_inputs.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs diff --git a/programs/compressed-token/program/src/transfer2/token_outputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs similarity index 100% rename from programs/compressed-token/program/src/transfer2/token_outputs.rs rename to programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs diff --git a/programs/compressed-token/program/src/claim.rs b/programs/compressed-token/program/src/compressible/claim.rs similarity index 98% rename from programs/compressed-token/program/src/claim.rs rename to programs/compressed-token/program/src/compressible/claim.rs index 8b5eef1203..d4330d5a8a 100644 --- a/programs/compressed-token/program/src/claim.rs +++ b/programs/compressed-token/program/src/compressible/claim.rs @@ -11,7 +11,7 @@ use pinocchio::{account_info::AccountInfo, sysvars::Sysvar}; use spl_pod::solana_msg::msg; use crate::{ - create_token_account::parse_config_account, + light_token::create_token_account::parse_config_account, shared::{convert_program_error, transfer_lamports}, }; diff --git a/programs/compressed-token/program/src/compressible/mod.rs b/programs/compressed-token/program/src/compressible/mod.rs new file mode 100644 index 0000000000..d1aeb32ff6 --- /dev/null +++ b/programs/compressed-token/program/src/compressible/mod.rs @@ -0,0 +1,5 @@ +pub mod claim; +pub mod withdraw_funding_pool; + +pub use claim::process_claim; +pub use withdraw_funding_pool::process_withdraw_funding_pool; diff --git a/programs/compressed-token/program/src/withdraw_funding_pool.rs b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs similarity index 98% rename from programs/compressed-token/program/src/withdraw_funding_pool.rs rename to programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs index 7d1bd1ddd7..1a53277e47 100644 --- a/programs/compressed-token/program/src/withdraw_funding_pool.rs +++ b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs @@ -8,7 +8,7 @@ use pinocchio::{ use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; -use crate::create_token_account::parse_config_account; +use crate::light_token::create_token_account::parse_config_account; /// Accounts required for the withdraw funding pool instruction pub struct WithdrawFundingPoolAccounts<'a> { diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index b78e633f85..94ba35e74d 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -5,41 +5,28 @@ use light_ctoken_interface::CTOKEN_PROGRAM_ID; use light_sdk::{cpi::CpiSigner, derive_light_cpi_signer}; use pinocchio::{account_info::AccountInfo, msg}; -pub mod claim; -pub mod close_token_account; +pub mod compressed_token; +pub mod compressible; pub mod convert_account_infos; -pub mod create_associated_token_account; -pub mod create_token_account; -pub mod ctoken_approve_revoke; -pub mod ctoken_burn; -pub mod ctoken_freeze_thaw; -pub mod ctoken_mint_to; pub mod extensions; -pub mod mint_action; +pub mod light_token; pub mod shared; -pub mod transfer; -pub mod transfer2; -pub mod withdraw_funding_pool; // Reexport the wrapped anchor program. pub use ::anchor_compressed_token::*; -use claim::process_claim; -use close_token_account::processor::process_close_token_account; -use create_associated_token_account::{ - process_create_associated_token_account, process_create_associated_token_account_idempotent, +use compressible::{process_claim, process_withdraw_funding_pool}; +use light_token::{ + process_close_token_account, process_create_associated_token_account, + process_create_associated_token_account_idempotent, process_create_token_account, + process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_burn, + process_ctoken_burn_checked, process_ctoken_freeze_account, process_ctoken_mint_to, + process_ctoken_mint_to_checked, process_ctoken_revoke, process_ctoken_thaw_account, + process_ctoken_transfer, process_ctoken_transfer_checked, }; -use create_token_account::process_create_token_account; -use ctoken_approve_revoke::{ - process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_revoke, -}; -use ctoken_burn::{process_ctoken_burn, process_ctoken_burn_checked}; -use ctoken_freeze_thaw::{process_ctoken_freeze_account, process_ctoken_thaw_account}; -use ctoken_mint_to::{process_ctoken_mint_to, process_ctoken_mint_to_checked}; -use transfer::{process_ctoken_transfer, process_ctoken_transfer_checked}; -use withdraw_funding_pool::process_withdraw_funding_pool; use crate::{ - convert_account_infos::convert_account_infos, mint_action::processor::process_mint_action, + compressed_token::mint_action::processor::process_mint_action, + convert_account_infos::convert_account_infos, }; pub const LIGHT_CPI_SIGNER: CpiSigner = @@ -135,7 +122,7 @@ impl From for InstructionType { #[cfg(not(feature = "cpi"))] use pinocchio::program_entrypoint; -use crate::transfer2::processor::process_transfer2; +use crate::compressed_token::transfer2::processor::process_transfer2; #[cfg(not(feature = "cpi"))] program_entrypoint!(process_instruction); diff --git a/programs/compressed-token/program/src/close_token_account/accounts.rs b/programs/compressed-token/program/src/light_token/close_token_account/accounts.rs similarity index 100% rename from programs/compressed-token/program/src/close_token_account/accounts.rs rename to programs/compressed-token/program/src/light_token/close_token_account/accounts.rs diff --git a/programs/compressed-token/program/src/close_token_account/mod.rs b/programs/compressed-token/program/src/light_token/close_token_account/mod.rs similarity index 100% rename from programs/compressed-token/program/src/close_token_account/mod.rs rename to programs/compressed-token/program/src/light_token/close_token_account/mod.rs diff --git a/programs/compressed-token/program/src/close_token_account/processor.rs b/programs/compressed-token/program/src/light_token/close_token_account/processor.rs similarity index 100% rename from programs/compressed-token/program/src/close_token_account/processor.rs rename to programs/compressed-token/program/src/light_token/close_token_account/processor.rs diff --git a/programs/compressed-token/program/src/create_associated_token_account.rs b/programs/compressed-token/program/src/light_token/create_associated_token_account.rs similarity index 99% rename from programs/compressed-token/program/src/create_associated_token_account.rs rename to programs/compressed-token/program/src/light_token/create_associated_token_account.rs index 07eb70e46e..002508d36b 100644 --- a/programs/compressed-token/program/src/create_associated_token_account.rs +++ b/programs/compressed-token/program/src/light_token/create_associated_token_account.rs @@ -7,8 +7,8 @@ use pinocchio::{account_info::AccountInfo, instruction::Seed}; use spl_pod::solana_msg::msg; use crate::{ - create_token_account::next_config_account, extensions::has_mint_extensions, + light_token::create_token_account::next_config_account, shared::{ convert_program_error, create_pda_account, initialize_ctoken_account::{ diff --git a/programs/compressed-token/program/src/create_token_account.rs b/programs/compressed-token/program/src/light_token/create_token_account.rs similarity index 100% rename from programs/compressed-token/program/src/create_token_account.rs rename to programs/compressed-token/program/src/light_token/create_token_account.rs diff --git a/programs/compressed-token/program/src/ctoken_approve_revoke.rs b/programs/compressed-token/program/src/light_token/ctoken_approve_revoke.rs similarity index 99% rename from programs/compressed-token/program/src/ctoken_approve_revoke.rs rename to programs/compressed-token/program/src/light_token/ctoken_approve_revoke.rs index f532cc684f..cb54ec26b1 100644 --- a/programs/compressed-token/program/src/ctoken_approve_revoke.rs +++ b/programs/compressed-token/program/src/light_token/ctoken_approve_revoke.rs @@ -7,11 +7,11 @@ use pinocchio_token_program::processor::{ }; use crate::{ + compressed_token::transfer2::compression::ctoken::process_compression_top_up, shared::{ convert_program_error, owner_validation::check_token_program_owner, transfer_lamports_via_cpi, }, - transfer2::compression::ctoken::process_compression_top_up, }; /// Account indices for approve instruction diff --git a/programs/compressed-token/program/src/ctoken_burn.rs b/programs/compressed-token/program/src/light_token/ctoken_burn.rs similarity index 100% rename from programs/compressed-token/program/src/ctoken_burn.rs rename to programs/compressed-token/program/src/light_token/ctoken_burn.rs diff --git a/programs/compressed-token/program/src/ctoken_freeze_thaw.rs b/programs/compressed-token/program/src/light_token/ctoken_freeze_thaw.rs similarity index 100% rename from programs/compressed-token/program/src/ctoken_freeze_thaw.rs rename to programs/compressed-token/program/src/light_token/ctoken_freeze_thaw.rs diff --git a/programs/compressed-token/program/src/ctoken_mint_to.rs b/programs/compressed-token/program/src/light_token/ctoken_mint_to.rs similarity index 100% rename from programs/compressed-token/program/src/ctoken_mint_to.rs rename to programs/compressed-token/program/src/light_token/ctoken_mint_to.rs diff --git a/programs/compressed-token/program/src/light_token/mod.rs b/programs/compressed-token/program/src/light_token/mod.rs new file mode 100644 index 0000000000..9d900191ea --- /dev/null +++ b/programs/compressed-token/program/src/light_token/mod.rs @@ -0,0 +1,21 @@ +pub mod close_token_account; +pub mod create_associated_token_account; +pub mod create_token_account; +pub mod ctoken_approve_revoke; +pub mod ctoken_burn; +pub mod ctoken_freeze_thaw; +pub mod ctoken_mint_to; +pub mod transfer; + +pub use close_token_account::processor::process_close_token_account; +pub use create_associated_token_account::{ + process_create_associated_token_account, process_create_associated_token_account_idempotent, +}; +pub use create_token_account::process_create_token_account; +pub use ctoken_approve_revoke::{ + process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_revoke, +}; +pub use ctoken_burn::{process_ctoken_burn, process_ctoken_burn_checked}; +pub use ctoken_freeze_thaw::{process_ctoken_freeze_account, process_ctoken_thaw_account}; +pub use ctoken_mint_to::{process_ctoken_mint_to, process_ctoken_mint_to_checked}; +pub use transfer::{process_ctoken_transfer, process_ctoken_transfer_checked}; diff --git a/programs/compressed-token/program/src/transfer/checked.rs b/programs/compressed-token/program/src/light_token/transfer/checked.rs similarity index 100% rename from programs/compressed-token/program/src/transfer/checked.rs rename to programs/compressed-token/program/src/light_token/transfer/checked.rs diff --git a/programs/compressed-token/program/src/transfer/default.rs b/programs/compressed-token/program/src/light_token/transfer/default.rs similarity index 97% rename from programs/compressed-token/program/src/transfer/default.rs rename to programs/compressed-token/program/src/light_token/transfer/default.rs index 37af0ce4eb..02cb3bb94e 100644 --- a/programs/compressed-token/program/src/transfer/default.rs +++ b/programs/compressed-token/program/src/light_token/transfer/default.rs @@ -3,7 +3,7 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::transfer::process_transfer; -use crate::transfer::shared::{process_transfer_extensions_transfer, TransferAccounts}; +use super::shared::{process_transfer_extensions_transfer, TransferAccounts}; /// Account indices for CToken transfer instruction const ACCOUNT_SOURCE: usize = 0; diff --git a/programs/compressed-token/program/src/transfer/mod.rs b/programs/compressed-token/program/src/light_token/transfer/mod.rs similarity index 100% rename from programs/compressed-token/program/src/transfer/mod.rs rename to programs/compressed-token/program/src/light_token/transfer/mod.rs diff --git a/programs/compressed-token/program/src/transfer/shared.rs b/programs/compressed-token/program/src/light_token/transfer/shared.rs similarity index 100% rename from programs/compressed-token/program/src/transfer/shared.rs rename to programs/compressed-token/program/src/light_token/transfer/shared.rs diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index 4cba7ec723..fb704cda37 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -16,8 +16,8 @@ use light_ctoken_interface::{ use pinocchio::account_info::AccountInfo; use crate::{ + compressed_token::transfer2::check_extensions::MintExtensionCache, shared::owner_validation::verify_owner_or_delegate_signer, - transfer2::check_extensions::MintExtensionCache, }; /// Creates an input compressed account using zero-copy patterns and index-based account lookup. From c17726b4765c54449384a6f77b73ab26cce4d7ba Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 5 Jan 2026 20:26:11 +0000 Subject: [PATCH 02/38] refactor: remove ctoken prefix from file names --- .../ctoken-interface/src/state/ctoken/size.rs | 4 ++-- .../mint_action/actions/decompress_mint.rs | 2 +- .../mint_action/actions/mint_to_ctoken.rs | 6 +++--- .../mint_action/mint_input.rs | 5 ++++- .../compression/ctoken/compress_and_close.rs | 4 ++-- .../program/src/compressible/claim.rs | 2 +- .../src/compressible/withdraw_funding_pool.rs | 2 +- .../approve_revoke.rs} | 0 .../ctoken_burn.rs => ctoken/burn.rs} | 0 .../close}/accounts.rs | 0 .../close}/mod.rs | 0 .../close}/processor.rs | 0 .../create.rs} | 0 .../create_ata.rs} | 2 +- .../freeze_thaw.rs} | 0 .../ctoken_mint_to.rs => ctoken/mint_to.rs} | 0 .../program/src/ctoken/mod.rs | 21 +++++++++++++++++++ .../transfer/checked.rs | 0 .../transfer/default.rs | 0 .../{light_token => ctoken}/transfer/mod.rs | 0 .../transfer/shared.rs | 0 programs/compressed-token/program/src/lib.rs | 4 ++-- .../program/src/light_token/mod.rs | 21 ------------------- 23 files changed, 38 insertions(+), 35 deletions(-) rename programs/compressed-token/program/src/{light_token/ctoken_approve_revoke.rs => ctoken/approve_revoke.rs} (100%) rename programs/compressed-token/program/src/{light_token/ctoken_burn.rs => ctoken/burn.rs} (100%) rename programs/compressed-token/program/src/{light_token/close_token_account => ctoken/close}/accounts.rs (100%) rename programs/compressed-token/program/src/{light_token/close_token_account => ctoken/close}/mod.rs (100%) rename programs/compressed-token/program/src/{light_token/close_token_account => ctoken/close}/processor.rs (100%) rename programs/compressed-token/program/src/{light_token/create_token_account.rs => ctoken/create.rs} (100%) rename programs/compressed-token/program/src/{light_token/create_associated_token_account.rs => ctoken/create_ata.rs} (99%) rename programs/compressed-token/program/src/{light_token/ctoken_freeze_thaw.rs => ctoken/freeze_thaw.rs} (100%) rename programs/compressed-token/program/src/{light_token/ctoken_mint_to.rs => ctoken/mint_to.rs} (100%) create mode 100644 programs/compressed-token/program/src/ctoken/mod.rs rename programs/compressed-token/program/src/{light_token => ctoken}/transfer/checked.rs (100%) rename programs/compressed-token/program/src/{light_token => ctoken}/transfer/default.rs (100%) rename programs/compressed-token/program/src/{light_token => ctoken}/transfer/mod.rs (100%) rename programs/compressed-token/program/src/{light_token => ctoken}/transfer/shared.rs (100%) delete mode 100644 programs/compressed-token/program/src/light_token/mod.rs diff --git a/program-libs/ctoken-interface/src/state/ctoken/size.rs b/program-libs/ctoken-interface/src/state/ctoken/size.rs index e53a6da551..e4c1dcba99 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/size.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/size.rs @@ -7,8 +7,8 @@ use crate::{ /// Calculates the size of a ctoken account based on which extensions are present. /// -/// Note: Compression info is now embedded in the base struct (CTokenZeroCopyMeta), -/// so there's no separate compressible extension parameter. +/// Note: Compressible extension is required if the T22 mint has restricted extensions +/// (Pausable, PermanentDelegate, TransferFee, TransferHook). /// /// # Arguments /// * `extensions` - Optional slice of extension configs diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index c157558ceb..db3261f79a 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -15,7 +15,7 @@ use spl_pod::solana_msg::msg; use crate::{ compressed_token::mint_action::accounts::MintActionAccounts, - light_token::create_token_account::parse_config_account, + ctoken::create::parse_config_account, shared::{ convert_program_error, create_pda_account::{create_pda_account, verify_pda}, diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs index 0945085351..03ce5c21eb 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs @@ -8,9 +8,9 @@ use light_ctoken_interface::{ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; -use crate::{ - compressed_token::mint_action::{accounts::MintActionAccounts, check_authority}, - compressed_token::transfer2::compression::{compress_or_decompress_ctokens, CTokenCompressionInputs}, +use crate::compressed_token::{ + mint_action::{accounts::MintActionAccounts, check_authority}, + transfer2::compression::{compress_or_decompress_ctokens, CTokenCompressionInputs}, }; #[allow(clippy::too_many_arguments)] diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs index a9fd961722..a48de9e63b 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs @@ -8,7 +8,10 @@ use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; use light_sdk::instruction::PackedMerkleContext; -use crate::{compressed_token::mint_action::accounts::AccountsConfig, constants::COMPRESSED_MINT_DISCRIMINATOR}; +use crate::{ + compressed_token::mint_action::accounts::AccountsConfig, + constants::COMPRESSED_MINT_DISCRIMINATOR, +}; /// Creates and validates an input compressed mint account. /// This function follows the same pattern as create_output_compressed_mint_account diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 1c046dd6d3..29d8816861 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -20,7 +20,7 @@ use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; use crate::{ compressed_token::transfer2::accounts::Transfer2Accounts, - light_token::close_token_account::{ + ctoken::close::{ accounts::CloseTokenAccountAccounts, processor::validate_token_account_for_close_transfer2, }, }; @@ -335,7 +335,7 @@ pub fn close_for_compress_and_close( let authority = validated_accounts .packed_accounts .get_u8(compression.authority, "CompressAndClose: authority")?; - use crate::light_token::close_token_account::processor::close_token_account; + use crate::ctoken::close::processor::close_token_account; close_token_account(&CloseTokenAccountAccounts { token_account: token_account_info, destination, diff --git a/programs/compressed-token/program/src/compressible/claim.rs b/programs/compressed-token/program/src/compressible/claim.rs index d4330d5a8a..b5129926d9 100644 --- a/programs/compressed-token/program/src/compressible/claim.rs +++ b/programs/compressed-token/program/src/compressible/claim.rs @@ -11,7 +11,7 @@ use pinocchio::{account_info::AccountInfo, sysvars::Sysvar}; use spl_pod::solana_msg::msg; use crate::{ - light_token::create_token_account::parse_config_account, + ctoken::create::parse_config_account, shared::{convert_program_error, transfer_lamports}, }; diff --git a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs index 1a53277e47..8cefc06d08 100644 --- a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs +++ b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs @@ -8,7 +8,7 @@ use pinocchio::{ use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; -use crate::light_token::create_token_account::parse_config_account; +use crate::ctoken::create::parse_config_account; /// Accounts required for the withdraw funding pool instruction pub struct WithdrawFundingPoolAccounts<'a> { diff --git a/programs/compressed-token/program/src/light_token/ctoken_approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/ctoken_approve_revoke.rs rename to programs/compressed-token/program/src/ctoken/approve_revoke.rs diff --git a/programs/compressed-token/program/src/light_token/ctoken_burn.rs b/programs/compressed-token/program/src/ctoken/burn.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/ctoken_burn.rs rename to programs/compressed-token/program/src/ctoken/burn.rs diff --git a/programs/compressed-token/program/src/light_token/close_token_account/accounts.rs b/programs/compressed-token/program/src/ctoken/close/accounts.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/close_token_account/accounts.rs rename to programs/compressed-token/program/src/ctoken/close/accounts.rs diff --git a/programs/compressed-token/program/src/light_token/close_token_account/mod.rs b/programs/compressed-token/program/src/ctoken/close/mod.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/close_token_account/mod.rs rename to programs/compressed-token/program/src/ctoken/close/mod.rs diff --git a/programs/compressed-token/program/src/light_token/close_token_account/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/close_token_account/processor.rs rename to programs/compressed-token/program/src/ctoken/close/processor.rs diff --git a/programs/compressed-token/program/src/light_token/create_token_account.rs b/programs/compressed-token/program/src/ctoken/create.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/create_token_account.rs rename to programs/compressed-token/program/src/ctoken/create.rs diff --git a/programs/compressed-token/program/src/light_token/create_associated_token_account.rs b/programs/compressed-token/program/src/ctoken/create_ata.rs similarity index 99% rename from programs/compressed-token/program/src/light_token/create_associated_token_account.rs rename to programs/compressed-token/program/src/ctoken/create_ata.rs index 002508d36b..4811d6eb4e 100644 --- a/programs/compressed-token/program/src/light_token/create_associated_token_account.rs +++ b/programs/compressed-token/program/src/ctoken/create_ata.rs @@ -6,9 +6,9 @@ use light_program_profiler::profile; use pinocchio::{account_info::AccountInfo, instruction::Seed}; use spl_pod::solana_msg::msg; +use super::create::next_config_account; use crate::{ extensions::has_mint_extensions, - light_token::create_token_account::next_config_account, shared::{ convert_program_error, create_pda_account, initialize_ctoken_account::{ diff --git a/programs/compressed-token/program/src/light_token/ctoken_freeze_thaw.rs b/programs/compressed-token/program/src/ctoken/freeze_thaw.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/ctoken_freeze_thaw.rs rename to programs/compressed-token/program/src/ctoken/freeze_thaw.rs diff --git a/programs/compressed-token/program/src/light_token/ctoken_mint_to.rs b/programs/compressed-token/program/src/ctoken/mint_to.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/ctoken_mint_to.rs rename to programs/compressed-token/program/src/ctoken/mint_to.rs diff --git a/programs/compressed-token/program/src/ctoken/mod.rs b/programs/compressed-token/program/src/ctoken/mod.rs new file mode 100644 index 0000000000..441bda4979 --- /dev/null +++ b/programs/compressed-token/program/src/ctoken/mod.rs @@ -0,0 +1,21 @@ +pub mod approve_revoke; +pub mod burn; +pub mod close; +pub mod create; +pub mod create_ata; +pub mod freeze_thaw; +pub mod mint_to; +pub mod transfer; + +pub use approve_revoke::{ + process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_revoke, +}; +pub use burn::{process_ctoken_burn, process_ctoken_burn_checked}; +pub use close::processor::process_close_token_account; +pub use create::process_create_token_account; +pub use create_ata::{ + process_create_associated_token_account, process_create_associated_token_account_idempotent, +}; +pub use freeze_thaw::{process_ctoken_freeze_account, process_ctoken_thaw_account}; +pub use mint_to::{process_ctoken_mint_to, process_ctoken_mint_to_checked}; +pub use transfer::{process_ctoken_transfer, process_ctoken_transfer_checked}; diff --git a/programs/compressed-token/program/src/light_token/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/transfer/checked.rs rename to programs/compressed-token/program/src/ctoken/transfer/checked.rs diff --git a/programs/compressed-token/program/src/light_token/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/transfer/default.rs rename to programs/compressed-token/program/src/ctoken/transfer/default.rs diff --git a/programs/compressed-token/program/src/light_token/transfer/mod.rs b/programs/compressed-token/program/src/ctoken/transfer/mod.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/transfer/mod.rs rename to programs/compressed-token/program/src/ctoken/transfer/mod.rs diff --git a/programs/compressed-token/program/src/light_token/transfer/shared.rs b/programs/compressed-token/program/src/ctoken/transfer/shared.rs similarity index 100% rename from programs/compressed-token/program/src/light_token/transfer/shared.rs rename to programs/compressed-token/program/src/ctoken/transfer/shared.rs diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index 94ba35e74d..16fda9cb7e 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -8,14 +8,14 @@ use pinocchio::{account_info::AccountInfo, msg}; pub mod compressed_token; pub mod compressible; pub mod convert_account_infos; +pub mod ctoken; pub mod extensions; -pub mod light_token; pub mod shared; // Reexport the wrapped anchor program. pub use ::anchor_compressed_token::*; use compressible::{process_claim, process_withdraw_funding_pool}; -use light_token::{ +use ctoken::{ process_close_token_account, process_create_associated_token_account, process_create_associated_token_account_idempotent, process_create_token_account, process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_burn, diff --git a/programs/compressed-token/program/src/light_token/mod.rs b/programs/compressed-token/program/src/light_token/mod.rs deleted file mode 100644 index 9d900191ea..0000000000 --- a/programs/compressed-token/program/src/light_token/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub mod close_token_account; -pub mod create_associated_token_account; -pub mod create_token_account; -pub mod ctoken_approve_revoke; -pub mod ctoken_burn; -pub mod ctoken_freeze_thaw; -pub mod ctoken_mint_to; -pub mod transfer; - -pub use close_token_account::processor::process_close_token_account; -pub use create_associated_token_account::{ - process_create_associated_token_account, process_create_associated_token_account_idempotent, -}; -pub use create_token_account::process_create_token_account; -pub use ctoken_approve_revoke::{ - process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_revoke, -}; -pub use ctoken_burn::{process_ctoken_burn, process_ctoken_burn_checked}; -pub use ctoken_freeze_thaw::{process_ctoken_freeze_account, process_ctoken_thaw_account}; -pub use ctoken_mint_to::{process_ctoken_mint_to, process_ctoken_mint_to_checked}; -pub use transfer::{process_ctoken_transfer, process_ctoken_transfer_checked}; From d567e6e4ee712fedc06a68a1874b3978eac36b99 Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 5 Jan 2026 20:35:49 +0000 Subject: [PATCH 03/38] refactor: compressed token program docs structure --- programs/compressed-token/program/CLAUDE.md | 62 ++++++++++---- .../compressed-token/program/docs/CLAUDE.md | 40 ++++++---- .../program/docs/EXTENSIONS.md | 10 +-- .../CLAUDE.md => INSTRUCTIONS.md} | 80 ++++++++++--------- .../ADD_TOKEN_POOL.md | 0 .../CREATE_TOKEN_POOL.md | 0 .../compressed_token/FREEZE.md | 0 .../MINT_ACTION.md | 2 +- .../compressed_token/THAW.md | 0 .../TRANSFER2.md | 2 +- .../{instructions => compressible}/CLAIM.md | 2 +- .../WITHDRAW_FUNDING_POOL.md | 2 +- .../CTOKEN_APPROVE.md => ctoken/APPROVE.md} | 4 +- .../APPROVE_CHECKED.md} | 4 +- .../CTOKEN_BURN.md => ctoken/BURN.md} | 2 +- .../BURN_CHECKED.md} | 2 +- .../CLOSE.md} | 2 +- .../CREATE.md} | 4 +- .../FREEZE_ACCOUNT.md} | 2 +- .../CTOKEN_MINT_TO.md => ctoken/MINT_TO.md} | 4 +- .../MINT_TO_CHECKED.md} | 4 +- .../CTOKEN_REVOKE.md => ctoken/REVOKE.md} | 4 +- .../THAW_ACCOUNT.md} | 2 +- .../CTOKEN_TRANSFER.md => ctoken/TRANSFER.md} | 2 +- .../TRANSFER_CHECKED.md} | 2 +- 25 files changed, 139 insertions(+), 99 deletions(-) rename programs/compressed-token/program/docs/{instructions/CLAUDE.md => INSTRUCTIONS.md} (52%) rename programs/compressed-token/program/docs/{instructions => compressed_token}/ADD_TOKEN_POOL.md (100%) rename programs/compressed-token/program/docs/{instructions => compressed_token}/CREATE_TOKEN_POOL.md (100%) rename programs/compressed-token/program/docs/{instructions => }/compressed_token/FREEZE.md (100%) rename programs/compressed-token/program/docs/{instructions => compressed_token}/MINT_ACTION.md (99%) rename programs/compressed-token/program/docs/{instructions => }/compressed_token/THAW.md (100%) rename programs/compressed-token/program/docs/{instructions => compressed_token}/TRANSFER2.md (99%) rename programs/compressed-token/program/docs/{instructions => compressible}/CLAIM.md (98%) rename programs/compressed-token/program/docs/{instructions => compressible}/WITHDRAW_FUNDING_POOL.md (97%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_APPROVE.md => ctoken/APPROVE.md} (98%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_APPROVE_CHECKED.md => ctoken/APPROVE_CHECKED.md} (98%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_BURN.md => ctoken/BURN.md} (99%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_BURN_CHECKED.md => ctoken/BURN_CHECKED.md} (99%) rename programs/compressed-token/program/docs/{instructions/CLOSE_TOKEN_ACCOUNT.md => ctoken/CLOSE.md} (99%) rename programs/compressed-token/program/docs/{instructions/CREATE_TOKEN_ACCOUNT.md => ctoken/CREATE.md} (98%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_FREEZE_ACCOUNT.md => ctoken/FREEZE_ACCOUNT.md} (98%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_MINT_TO.md => ctoken/MINT_TO.md} (98%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_MINT_TO_CHECKED.md => ctoken/MINT_TO_CHECKED.md} (97%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_REVOKE.md => ctoken/REVOKE.md} (97%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_THAW_ACCOUNT.md => ctoken/THAW_ACCOUNT.md} (98%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_TRANSFER.md => ctoken/TRANSFER.md} (98%) rename programs/compressed-token/program/docs/{instructions/CTOKEN_TRANSFER_CHECKED.md => ctoken/TRANSFER_CHECKED.md} (99%) diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index 2c40ce208a..34b21a916c 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -115,29 +115,59 @@ Every instruction description must include the sections: # Source Code Structure (`src/`) -## Core Instructions -- **`create_token_account.rs`** - Create regular ctoken accounts with optional compressible extension -- **`create_associated_token_account.rs`** - Create deterministic ATA accounts -- **`close_token_account/`** - Close ctoken accounts, handle rent distribution -- **`transfer/`** - SPL-compatible transfers between decompressed accounts - - `default.rs` - CTokenTransfer (discriminator: 3) - - `checked.rs` - CTokenTransferChecked (discriminator: 12) - - `shared.rs` - Common transfer utilities - -## Token Operations +``` +src/ +├── compressed_token/ # Operations on compressed accounts (in Merkle trees) +│ ├── mint_action/ # MintAction instruction (103) +│ └── transfer2/ # Transfer2 instruction (101) +├── compressible/ # Rent management +│ ├── claim.rs # Claim instruction (104) +│ └── withdraw_funding_pool.rs # WithdrawFundingPool instruction (105) +├── ctoken/ # Operations on CToken Solana accounts (decompressed) +│ ├── approve_revoke.rs # CTokenApprove (4), CTokenRevoke (5), CTokenApproveChecked (13) +│ ├── burn.rs # CTokenBurn (8), CTokenBurnChecked (15) +│ ├── close/ # CloseTokenAccount instruction (9) +│ ├── create.rs # CreateTokenAccount instruction (18) +│ ├── create_ata.rs # CreateAssociatedCTokenAccount (100, 102) +│ ├── freeze_thaw.rs # CTokenFreezeAccount (10), CTokenThawAccount (11) +│ ├── mint_to.rs # CTokenMintTo (7), CTokenMintToChecked (14) +│ └── transfer/ # CTokenTransfer (3), CTokenTransferChecked (12) +├── extensions/ # Extension handling +├── shared/ # Common utilities +├── convert_account_infos.rs +└── lib.rs # Entry point and instruction dispatch +``` + +## Compressed Token Operations (`compressed_token/`) +Operations on compressed accounts stored in Merkle trees. + +- **`mint_action/`** - MintAction instruction for compressed mint management + - `processor.rs` - Main instruction processor + - `accounts.rs` - Account validation and parsing + - `actions/` - Individual action handlers (create_mint, mint_to, decompress_mint, etc.) - **`transfer2/`** - Unified transfer instruction supporting multiple modes - `compression/` - Compress & decompress functionality - `ctoken/` - CToken-specific compression (compress_and_close.rs, decompress.rs, etc.) - `spl.rs` - SPL token compression - `processor.rs` - Main instruction processor - `accounts.rs` - Account validation and parsing -- **`mint_action/`** - Mint tokens to compressed/decompressed accounts -- **`ctoken_approve_revoke.rs`** - CTokenApprove (4), CTokenRevoke (5), CTokenApproveChecked (13) -- **`ctoken_mint_to.rs`** - CTokenMintTo (7), CTokenMintToChecked (14) -- **`ctoken_burn.rs`** - CTokenBurn (8), CTokenBurnChecked (15) -- **`ctoken_freeze_thaw.rs`** - CTokenFreezeAccount (10), CTokenThawAccount (11) -## Rent Management +## CToken Operations (`ctoken/`) +Operations on CToken Solana accounts (decompressed compressed tokens). + +- **`create.rs`** - Create regular ctoken accounts with optional compressible extension +- **`create_ata.rs`** - Create deterministic ATA accounts +- **`close/`** - Close ctoken accounts, handle rent distribution +- **`transfer/`** - SPL-compatible transfers between decompressed accounts + - `default.rs` - CTokenTransfer (discriminator: 3) + - `checked.rs` - CTokenTransferChecked (discriminator: 12) + - `shared.rs` - Common transfer utilities +- **`approve_revoke.rs`** - CTokenApprove (4), CTokenRevoke (5), CTokenApproveChecked (13) +- **`mint_to.rs`** - CTokenMintTo (7), CTokenMintToChecked (14) +- **`burn.rs`** - CTokenBurn (8), CTokenBurnChecked (15) +- **`freeze_thaw.rs`** - CTokenFreezeAccount (10), CTokenThawAccount (11) + +## Rent Management (`compressible/`) - **`claim.rs`** - Claim rent from expired compressible accounts - **`withdraw_funding_pool.rs`** - Withdraw funds from rent recipient pool diff --git a/programs/compressed-token/program/docs/CLAUDE.md b/programs/compressed-token/program/docs/CLAUDE.md index 461b7bf356..3f328b6b7c 100644 --- a/programs/compressed-token/program/docs/CLAUDE.md +++ b/programs/compressed-token/program/docs/CLAUDE.md @@ -8,30 +8,36 @@ This documentation is organized to provide clear navigation through the compress - **`../CLAUDE.md`** (parent) - Main entry point with summary and instruction index - **`ACCOUNTS.md`** - Complete account layouts and data structures - **`EXTENSIONS.md`** - Token-2022 extension validation across ctoken instructions +- **`INSTRUCTIONS.md`** - Full instruction reference and discriminator table - **`RESTRICTED_T22_EXTENSIONS.md`** - SPL Token-2022 behavior for 5 restricted extensions - **`T22_VS_CTOKEN_COMPARISON.md`** - Comparison of T22 vs ctoken extension behavior -- **`instructions/`** - Detailed instruction documentation - - `CREATE_TOKEN_ACCOUNT.md` - Create token account & associated token account instructions +- **`compressed_token/`** - Compressed token operations (Merkle tree accounts) + - `TRANSFER2.md` - Batch transfer with compress/decompress operations - `MINT_ACTION.md` - Mint operations and compressed mint management - - `TRANSFER2.md` - Batch transfer instruction for compressed/decompressed operations - - `CLAIM.md` - Claim rent from expired compressible accounts - - `CLOSE_TOKEN_ACCOUNT.md` - Close decompressed token accounts - - `CTOKEN_TRANSFER.md` - Transfer between decompressed accounts - - `CTOKEN_TRANSFER_CHECKED.md` - Transfer with decimals validation - - `CTOKEN_APPROVE.md` - Approve delegate on decompressed CToken account - - `CTOKEN_REVOKE.md` - Revoke delegate on decompressed CToken account - - `CTOKEN_MINT_TO.md` - Mint tokens to decompressed CToken account - - `CTOKEN_BURN.md` - Burn tokens from decompressed CToken account - - `CTOKEN_FREEZE_ACCOUNT.md` - Freeze decompressed CToken account - - `CTOKEN_THAW_ACCOUNT.md` - Thaw frozen decompressed CToken account - - `CTOKEN_APPROVE_CHECKED.md` - Approve delegate with decimals validation - - `CTOKEN_MINT_TO_CHECKED.md` - Mint tokens with decimals validation - - `CTOKEN_BURN_CHECKED.md` - Burn tokens with decimals validation - - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool + - `FREEZE.md` - Freeze compressed token accounts (Anchor) + - `THAW.md` - Thaw frozen compressed token accounts (Anchor) - `CREATE_TOKEN_POOL.md` - Create initial token pool for SPL/T22 mint compression - `ADD_TOKEN_POOL.md` - Add additional token pools (up to 5 per mint) +- **`compressible/`** - Rent management for compressible accounts + - `CLAIM.md` - Claim rent from expired compressible accounts + - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool +- **`ctoken/`** - CToken (decompressed) account operations + - `CREATE.md` - Create token account & associated token account + - `CLOSE.md` - Close decompressed token accounts + - `TRANSFER.md` - Transfer between decompressed accounts + - `TRANSFER_CHECKED.md` - Transfer with decimals validation + - `APPROVE.md` - Approve delegate + - `APPROVE_CHECKED.md` - Approve with decimals validation + - `REVOKE.md` - Revoke delegate + - `MINT_TO.md` - Mint tokens to CToken account + - `MINT_TO_CHECKED.md` - Mint with decimals validation + - `BURN.md` - Burn tokens from CToken account + - `BURN_CHECKED.md` - Burn with decimals validation + - `FREEZE_ACCOUNT.md` - Freeze CToken account + - `THAW_ACCOUNT.md` - Thaw frozen CToken account ## Navigation Tips - Start with `../CLAUDE.md` for the instruction index and overview - Use `ACCOUNTS.md` for account structure reference +- Use `INSTRUCTIONS.md` for discriminator reference and instruction index - Refer to specific instruction docs for implementation details diff --git a/programs/compressed-token/program/docs/EXTENSIONS.md b/programs/compressed-token/program/docs/EXTENSIONS.md index cf70935947..d2c39d30ec 100644 --- a/programs/compressed-token/program/docs/EXTENSIONS.md +++ b/programs/compressed-token/program/docs/EXTENSIONS.md @@ -136,7 +136,7 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric **Validation paths:** - `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:77-84` - Extracts delegate pubkey in `parse_mint_extensions()` - `programs/compressed-token/program/src/shared/owner_validation.rs:30-78` - `verify_owner_or_delegate_signer()` validates delegate/permanent delegate signer -- `programs/compressed-token/program/src/transfer/shared.rs:164-179` - `validate_permanent_delegate()` +- `programs/compressed-token/program/src/ctoken/transfer/shared.rs:164-179` - `validate_permanent_delegate()` **Unchecked instructions:** 1. CTokenApprove @@ -247,7 +247,7 @@ pub struct CompressedOnlyExtensionInstructionData { ### When Created (CompressAndClose) -**Path:** `programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs` **Trigger:** `ZCompressionMode::CompressAndClose` with `compression_only=true` on source CToken account. @@ -274,7 +274,7 @@ ctoken.base.set_initialized(); ### When Consumed (Decompress) -**Path:** `programs/compressed-token/program/src/transfer2/compression/ctoken/decompress.rs` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs` **Trigger:** Decompressing a compressed token that has CompressedOnly extension. @@ -409,7 +409,7 @@ MintExtensionChecks { --- ### `build_mint_extension_cache()` -**Path:** `programs/compressed-token/program/src/transfer2/check_extensions.rs:77-145` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs:77-145` **Used by:** Transfer2 (batch validation) @@ -458,7 +458,7 @@ MintExtensionChecks { - CompressAndClose still requires CompressedOnly output extension for restricted mints (lines 116-137) - If missing → `CompressAndCloseMissingCompressedOnlyExtension` (6133) -**Path:** `programs/compressed-token/program/src/transfer2/processor.rs:61` calls `build_mint_extension_cache()` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/processor.rs:61` calls `build_mint_extension_cache()` ### Anchor Instructions diff --git a/programs/compressed-token/program/docs/instructions/CLAUDE.md b/programs/compressed-token/program/docs/INSTRUCTIONS.md similarity index 52% rename from programs/compressed-token/program/docs/instructions/CLAUDE.md rename to programs/compressed-token/program/docs/INSTRUCTIONS.md index 31bc2c2d9c..5e769a590b 100644 --- a/programs/compressed-token/program/docs/instructions/CLAUDE.md +++ b/programs/compressed-token/program/docs/INSTRUCTIONS.md @@ -8,28 +8,30 @@ This documentation is organized to provide clear navigation through the compress - **`../CLAUDE.md`** (parent) - Main entry point with summary and instruction index - **`ACCOUNTS.md`** - Complete account layouts and data structures - **`instructions/`** - Detailed instruction documentation - - `CREATE_TOKEN_ACCOUNT.md` - Create token account & associated token account instructions - - `MINT_ACTION.md` - Mint operations and compressed mint management - - `TRANSFER2.md` - Batch transfer instruction for compressed/decompressed operations - - `CLAIM.md` - Claim rent from expired compressible accounts - - `CLOSE_TOKEN_ACCOUNT.md` - Close decompressed token accounts - - `CTOKEN_TRANSFER.md` - Transfer between decompressed accounts - - `CTOKEN_TRANSFER_CHECKED.md` - Transfer with decimals validation - - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool + - **`compressed_token/`** - Compressed token operations (Merkle tree accounts) + - `TRANSFER2.md` - Batch transfer with compress/decompress operations + - `MINT_ACTION.md` - Mint operations and compressed mint management + - `FREEZE.md` - Freeze compressed token accounts (Anchor) + - `THAW.md` - Thaw frozen compressed token accounts (Anchor) + - **`compressible/`** - Rent management for compressible accounts + - `CLAIM.md` - Claim rent from expired compressible accounts + - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool + - **`ctoken/`** - CToken (decompressed) account operations + - `CREATE.md` - Create token account & associated token account + - `CLOSE.md` - Close decompressed token accounts + - `TRANSFER.md` - Transfer between decompressed accounts + - `TRANSFER_CHECKED.md` - Transfer with decimals validation + - `APPROVE.md` - Approve delegate + - `APPROVE_CHECKED.md` - Approve with decimals validation + - `REVOKE.md` - Revoke delegate + - `MINT_TO.md` - Mint tokens to CToken account + - `MINT_TO_CHECKED.md` - Mint with decimals validation + - `BURN.md` - Burn tokens from CToken account + - `BURN_CHECKED.md` - Burn with decimals validation + - `FREEZE_ACCOUNT.md` - Freeze CToken account + - `THAW_ACCOUNT.md` - Thaw frozen CToken account - `CREATE_TOKEN_POOL.md` - Create initial token pool for SPL/T22 mint compression - `ADD_TOKEN_POOL.md` - Add additional token pools (up to 5 per mint) - - `CTOKEN_APPROVE.md` - Approve delegate on decompressed CToken account - - `CTOKEN_REVOKE.md` - Revoke delegate on decompressed CToken account - - `CTOKEN_MINT_TO.md` - Mint tokens to decompressed CToken account - - `CTOKEN_BURN.md` - Burn tokens from decompressed CToken account - - `CTOKEN_FREEZE_ACCOUNT.md` - Freeze decompressed CToken account - - `CTOKEN_THAW_ACCOUNT.md` - Thaw frozen decompressed CToken account - - `CTOKEN_APPROVE_CHECKED.md` - Approve delegate with decimals validation - - `CTOKEN_MINT_TO_CHECKED.md` - Mint tokens with decimals validation - - `CTOKEN_BURN_CHECKED.md` - Burn tokens with decimals validation - - `compressed_token/` - Anchor program instructions for compressed token accounts - - `FREEZE.md` - Freeze compressed token accounts - - `THAW.md` - Thaw frozen compressed token accounts ## Discriminator Reference @@ -79,24 +81,26 @@ every instruction description must include the sections: - **instruciton logic and checks** - **Errors** possible errors and description what causes these errors -1. **Create Token Account Instructions** - Create regular and associated ctoken accounts -2. **Transfer2** - Batch transfer instruction supporting compress/decompress/transfer operations -3. **MintAction** - Batch instruction for compressed mint management and mint operations (supports 9 actions: CreateCompressedMint, MintTo, UpdateMintAuthority, UpdateFreezeAuthority, CreateSplMint, MintToCToken, UpdateMetadataField, UpdateMetadataAuthority, RemoveMetadataKey) -4. **Claim** - Rent reclamation from expired compressible accounts -5. **Close Token Account** - Close decompressed token accounts with rent distribution -6. **Decompressed Transfer** - SPL-compatible transfers between decompressed accounts -7. **Withdraw Funding Pool** - Withdraw funds from rent recipient pool -8. **Create Token Pool** - Create initial token pool PDA for SPL/T22 mint compression -9. **Add Token Pool** - Add additional token pools for a mint (up to 5 per mint) -10. **CToken MintTo** - Mint tokens to decompressed CToken account -11. **CToken Burn** - Burn tokens from decompressed CToken account -12. **CToken Freeze/Thaw** - Freeze and thaw decompressed CToken accounts -13. **CToken Approve/Revoke** - Approve and revoke delegate on decompressed CToken accounts -14. **CToken Checked Operations** - ApproveChecked, MintToChecked, BurnChecked with decimals validation +## Compressed Token Operations (`compressed_token/`) +1. **Transfer2** - Batch transfer instruction supporting compress/decompress/transfer operations +2. **MintAction** - Batch instruction for compressed mint management (9 actions) +3. **Freeze** - Freeze compressed token accounts (Anchor) +4. **Thaw** - Thaw frozen compressed token accounts (Anchor) -## Anchor Program Instructions (Compressed Token Accounts) +## CToken Operations (`ctoken/`) +5. **Create** - Create regular and associated ctoken accounts +6. **Close** - Close decompressed token accounts with rent distribution +7. **Transfer** - SPL-compatible transfers between decompressed accounts +8. **Approve/Revoke** - Approve and revoke delegate on decompressed CToken accounts +9. **MintTo** - Mint tokens to decompressed CToken account +10. **Burn** - Burn tokens from decompressed CToken account +11. **Freeze/Thaw** - Freeze and thaw decompressed CToken accounts +12. **Checked Operations** - TransferChecked, ApproveChecked, MintToChecked, BurnChecked -These instructions operate on compressed token accounts (stored in Merkle trees) and require ZK proofs: +## Compressible Operations (`compressible/`) +13. **Claim** - Rent reclamation from expired compressible accounts +14. **Withdraw Funding Pool** - Withdraw funds from rent recipient pool -15. **Compressed Token Freeze** (`compressed_token/FREEZE.md`) - Freeze compressed token accounts -16. **Compressed Token Thaw** (`compressed_token/THAW.md`) - Thaw frozen compressed token accounts +## Token Pool Operations (root) +15. **Create Token Pool** - Create initial token pool PDA for SPL/T22 mint compression +16. **Add Token Pool** - Add additional token pools for a mint (up to 5 per mint) diff --git a/programs/compressed-token/program/docs/instructions/ADD_TOKEN_POOL.md b/programs/compressed-token/program/docs/compressed_token/ADD_TOKEN_POOL.md similarity index 100% rename from programs/compressed-token/program/docs/instructions/ADD_TOKEN_POOL.md rename to programs/compressed-token/program/docs/compressed_token/ADD_TOKEN_POOL.md diff --git a/programs/compressed-token/program/docs/instructions/CREATE_TOKEN_POOL.md b/programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md similarity index 100% rename from programs/compressed-token/program/docs/instructions/CREATE_TOKEN_POOL.md rename to programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md diff --git a/programs/compressed-token/program/docs/instructions/compressed_token/FREEZE.md b/programs/compressed-token/program/docs/compressed_token/FREEZE.md similarity index 100% rename from programs/compressed-token/program/docs/instructions/compressed_token/FREEZE.md rename to programs/compressed-token/program/docs/compressed_token/FREEZE.md diff --git a/programs/compressed-token/program/docs/instructions/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md similarity index 99% rename from programs/compressed-token/program/docs/instructions/MINT_ACTION.md rename to programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index 8157914f2a..e26737a365 100644 --- a/programs/compressed-token/program/docs/instructions/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -2,7 +2,7 @@ **discriminator:** 103 **enum:** `InstructionType::MintAction` -**path:** programs/compressed-token/program/src/mint_action/ +**path:** programs/compressed-token/program/src/compressed_token/mint_action/ **description:** Batch instruction for managing compressed mint accounts (cmints) and performing mint operations. A compressed mint account stores the mint's supply, decimals, authorities (mint/freeze), and optional TokenMetadata extension in compressed state. TokenMetadata is the only extension supported for compressed mints and provides fields for name, symbol, uri, update_authority, and additional key-value metadata. diff --git a/programs/compressed-token/program/docs/instructions/compressed_token/THAW.md b/programs/compressed-token/program/docs/compressed_token/THAW.md similarity index 100% rename from programs/compressed-token/program/docs/instructions/compressed_token/THAW.md rename to programs/compressed-token/program/docs/compressed_token/THAW.md diff --git a/programs/compressed-token/program/docs/instructions/TRANSFER2.md b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md similarity index 99% rename from programs/compressed-token/program/docs/instructions/TRANSFER2.md rename to programs/compressed-token/program/docs/compressed_token/TRANSFER2.md index 1bf886d016..56a15f97d2 100644 --- a/programs/compressed-token/program/docs/instructions/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -14,7 +14,7 @@ **discriminator:** 101 **enum:** `InstructionType::Transfer2` -**path:** programs/compressed-token/program/src/transfer2/ +**path:** programs/compressed-token/program/src/compressed_token/transfer2/ **description:** 1. Batch transfer instruction supporting multiple token operations in a single transaction with up to 5 different mints (cmints or spl) diff --git a/programs/compressed-token/program/docs/instructions/CLAIM.md b/programs/compressed-token/program/docs/compressible/CLAIM.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CLAIM.md rename to programs/compressed-token/program/docs/compressible/CLAIM.md index d49af25124..c642b2fa4d 100644 --- a/programs/compressed-token/program/docs/instructions/CLAIM.md +++ b/programs/compressed-token/program/docs/compressible/CLAIM.md @@ -2,7 +2,7 @@ **discriminator:** 104 **enum:** `InstructionType::Claim` -**path:** programs/compressed-token/program/src/claim.rs +**path:** programs/compressed-token/program/src/compressible/claim.rs **description:** 1. Claims rent from compressible CToken and CMint solana accounts that have passed their rent expiration epochs diff --git a/programs/compressed-token/program/docs/instructions/WITHDRAW_FUNDING_POOL.md b/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md similarity index 97% rename from programs/compressed-token/program/docs/instructions/WITHDRAW_FUNDING_POOL.md rename to programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md index f13a03b0b3..b87d71d186 100644 --- a/programs/compressed-token/program/docs/instructions/WITHDRAW_FUNDING_POOL.md +++ b/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md @@ -2,7 +2,7 @@ **discriminator:** 105 **enum:** `InstructionType::WithdrawFundingPool` -**path:** programs/compressed-token/program/src/withdraw_funding_pool.rs +**path:** programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs **description:** 1. Withdraws lamports from the rent_sponsor PDA pool to a specified destination account diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE.md b/programs/compressed-token/program/docs/ctoken/APPROVE.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE.md rename to programs/compressed-token/program/docs/ctoken/APPROVE.md index 8fcac00f4f..dfeadfe66b 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE.md +++ b/programs/compressed-token/program/docs/ctoken/APPROVE.md @@ -2,7 +2,7 @@ **discriminator:** 4 **enum:** `InstructionType::CTokenApprove` -**path:** programs/compressed-token/program/src/ctoken_approve_revoke.rs +**path:** programs/compressed-token/program/src/ctoken/approve_revoke.rs ### SPL Instruction Format Compatibility This instruction is compatible with the SPL Token instruction format (using `spl_token_2022::instruction::approve` with changed program ID) when **no top-up is required**. @@ -17,7 +17,7 @@ If the CToken account has a compressible extension and requires a rent top-up, t Delegates a specified amount to a delegate authority on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). Before the approve operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible approve semantics. Supports backwards-compatible instruction data format (8 bytes legacy vs 10 bytes with max_top_up). **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_approve_revoke.rs (lines 34-66) +Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 34-66) - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to delegate - Bytes 8-9 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit, default for legacy format) diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE_CHECKED.md b/programs/compressed-token/program/docs/ctoken/APPROVE_CHECKED.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE_CHECKED.md rename to programs/compressed-token/program/docs/ctoken/APPROVE_CHECKED.md index c6ed24fe45..54ff45369a 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_APPROVE_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/APPROVE_CHECKED.md @@ -2,13 +2,13 @@ **discriminator:** 13 **enum:** `InstructionType::CTokenApproveChecked` -**path:** programs/compressed-token/program/src/ctoken_approve_revoke.rs +**path:** programs/compressed-token/program/src/ctoken/approve_revoke.rs **description:** Delegates a specified amount to a delegate authority on a decompressed ctoken account with decimals validation, fully compatible with SPL Token ApproveChecked semantics. Account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. Extension layout `CompressionInfo` is defined in program-libs/compressible/src/compression_info.rs. Uses pinocchio-token-program to process the approve operation. Before the approve operation, automatically tops up compressible accounts with additional lamports if needed to prevent accounts from becoming compressible during normal operations. Supports max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses cached decimals optimization: if source CToken has cached decimals, validates against instruction decimals and skips mint read. Cached decimals allow users to choose whether a cmint is required to be decompressed at account creation or transfer. **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_approve_revoke.rs (lines 163-217) +Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 163-217) - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to delegate - Byte 8: `decimals` (u8) - Expected token decimals diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN.md b/programs/compressed-token/program/docs/ctoken/BURN.md similarity index 99% rename from programs/compressed-token/program/docs/instructions/CTOKEN_BURN.md rename to programs/compressed-token/program/docs/ctoken/BURN.md index 86cf799197..43d168ed1b 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN.md +++ b/programs/compressed-token/program/docs/ctoken/BURN.md @@ -2,7 +2,7 @@ **discriminator:** 8 **enum:** `InstructionType::CTokenBurn` -**path:** programs/compressed-token/program/src/ctoken_burn.rs +**path:** programs/compressed-token/program/src/ctoken/burn.rs **description:** Burns tokens from a decompressed CToken account and decreases the CMint supply, fully compatible with SPL Token burn semantics. Account layout `CToken` is defined in `program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs`. Account layout `CompressedMint` (CMint) is defined in `program-libs/ctoken-interface/src/state/mint/compressed_mint.rs`. Extension layout `CompressionInfo` is defined in `program-libs/compressible/src/compression_info.rs` and is embedded in both CToken and CMint structs. Uses pinocchio-token-program to process the burn (handles balance/supply updates, authority check, frozen check). After the burn, automatically tops up compressible accounts with additional lamports if needed. Top-up is calculated for both CMint and source CToken based on current slot and account balance. Top-up prevents accounts from becoming compressible during normal operations. Enforces max_top_up limit if provided (transaction fails if exceeded). Supports max_top_up parameter to limit rent top-up costs (0 = no limit). Instruction data is backwards-compatible: 8-byte format (legacy, no max_top_up enforcement) and 10-byte format (with max_top_up). This instruction only works with CMints (compressed mints). CMints do not support restricted Token-2022 extensions (Pausable, TransferFee, TransferHook, PermanentDelegate, DefaultAccountState) - only TokenMetadata is allowed. To burn tokens from spl or T22 mints, use Transfer2 with decompress mode to convert to SPL tokens first, then burn via SPL Token-2022. diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN_CHECKED.md b/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md similarity index 99% rename from programs/compressed-token/program/docs/instructions/CTOKEN_BURN_CHECKED.md rename to programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md index bfb0712561..ff170ea7be 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_BURN_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md @@ -2,7 +2,7 @@ **discriminator:** 15 **enum:** `InstructionType::CTokenBurnChecked` -**path:** programs/compressed-token/program/src/ctoken_burn.rs +**path:** programs/compressed-token/program/src/ctoken/burn.rs **description:** Burns tokens from a decompressed CToken account and decreases the CMint supply with decimals validation, fully compatible with SPL Token BurnChecked semantics. Account layout `CToken` is defined in `program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs`. Account layout `CompressedMint` (CMint) is defined in `program-libs/ctoken-interface/src/state/mint/compressed_mint.rs`. Extension layout `CompressionInfo` is defined in `program-libs/compressible/src/compression_info.rs` and is embedded in both CToken and CMint structs. Uses pinocchio-token-program to process the burn_checked (handles balance/supply updates, authority check, frozen check, decimals validation). After the burn, automatically tops up compressible accounts with additional lamports if needed. Top-up prevents accounts from becoming compressible during normal operations. Enforces max_top_up limit if provided (transaction fails if exceeded). Account order is REVERSED from mint_to instruction: [source_ctoken, cmint, authority] vs mint_to's [cmint, destination_ctoken, authority]. diff --git a/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/CLOSE.md similarity index 99% rename from programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md rename to programs/compressed-token/program/docs/ctoken/CLOSE.md index be6e2fa198..9836104503 100644 --- a/programs/compressed-token/program/docs/instructions/CLOSE_TOKEN_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/CLOSE.md @@ -2,7 +2,7 @@ **discriminator:** 9 **enum:** `CTokenInstruction::CloseTokenAccount` -**path:** programs/compressed-token/program/src/close_token_account/ +**path:** programs/compressed-token/program/src/ctoken/close/ **description:** 1. Closes decompressed ctoken solana accounts and distributes remaining lamports to destination account. diff --git a/programs/compressed-token/program/docs/instructions/CREATE_TOKEN_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/CREATE.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CREATE_TOKEN_ACCOUNT.md rename to programs/compressed-token/program/docs/ctoken/CREATE.md index 4713c8c626..1f68f51fa2 100644 --- a/programs/compressed-token/program/docs/instructions/CREATE_TOKEN_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/CREATE.md @@ -15,7 +15,7 @@ **discriminator:** 18 **enum:** `CTokenInstruction::CreateTokenAccount` - **path:** programs/compressed-token/program/src/create_token_account.rs + **path:** programs/compressed-token/program/src/ctoken/create.rs **description:** 1. creates ctoken solana accounts with and without Compressible extension @@ -131,7 +131,7 @@ **discriminator:** 100 (non-idempotent), 102 (idempotent) **enum:** `CTokenInstruction::CreateAssociatedCTokenAccount` (non-idempotent), `CTokenInstruction::CreateAssociatedTokenAccountIdempotent` (idempotent) - **path:** programs/compressed-token/program/src/create_associated_token_account.rs + **path:** programs/compressed-token/program/src/ctoken/create_ata.rs **description:** 1. Creates deterministic ctoken PDA accounts derived from [owner, ctoken_program_id, mint] diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_FREEZE_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CTOKEN_FREEZE_ACCOUNT.md rename to programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md index c71e90bb2c..9e4556bde0 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_FREEZE_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md @@ -2,7 +2,7 @@ **discriminator:** 10 **enum:** `InstructionType::CTokenFreezeAccount` -**path:** programs/compressed-token/program/src/ctoken_freeze_thaw.rs +**path:** programs/compressed-token/program/src/ctoken/freeze_thaw.rs **description:** Freezes a decompressed ctoken account, preventing transfers and other operations while frozen. This is a pass-through instruction that validates mint ownership (must be owned by SPL Token, Token-2022, or CToken program) before delegating to pinocchio-token-program for standard SPL Token freeze validation. After freezing, the account's state field is set to AccountState::Frozen, and only the freeze_authority of the mint can freeze accounts (mint must have freeze_authority set). The account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO.md b/programs/compressed-token/program/docs/ctoken/MINT_TO.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO.md rename to programs/compressed-token/program/docs/ctoken/MINT_TO.md index 642b2154ca..e7767a26d3 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO.md +++ b/programs/compressed-token/program/docs/ctoken/MINT_TO.md @@ -2,7 +2,7 @@ **discriminator:** 7 **enum:** `InstructionType::CTokenMintTo` -**path:** programs/compressed-token/program/src/ctoken_mint_to.rs +**path:** programs/compressed-token/program/src/ctoken/mint_to.rs **description:** Mints tokens from a decompressed CMint account to a destination CToken account, fully compatible with SPL Token mint_to semantics. Uses pinocchio-token-program to process the mint_to operation which handles balance/supply updates, authority validation, and frozen account checks. After minting, automatically tops up compressible accounts with additional lamports if needed to prevent accounts from becoming compressible during normal operations. Both CMint and destination CToken can receive top-ups based on their current slot and account balance. Supports max_top_up parameter to limit rent top-up costs where 0 means no limit. Instruction data is backwards-compatible with two formats: 8-byte format for legacy compatibility without max_top_up enforcement and 10-byte format with max_top_up. This instruction only works with CMints (compressed mints). CMints do not support restricted Token-2022 extensions (Pausable, TransferFee, TransferHook, PermanentDelegate, DefaultAccountState) - only TokenMetadata is allowed. @@ -13,7 +13,7 @@ Account layouts: - `CompressionInfo` extension defined in: program-libs/compressible/src/compression_info.rs **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_mint_to.rs (lines 10-47) +Path: programs/compressed-token/program/src/ctoken/mint_to.rs (lines 10-47) Byte layout: - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to mint diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO_CHECKED.md b/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md similarity index 97% rename from programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO_CHECKED.md rename to programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md index 08dc938c9e..07ef6f261e 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_MINT_TO_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md @@ -2,7 +2,7 @@ **discriminator:** 14 **enum:** `InstructionType::CTokenMintToChecked` -**path:** programs/compressed-token/program/src/ctoken_mint_to.rs +**path:** programs/compressed-token/program/src/ctoken/mint_to.rs **description:** Mints tokens from a decompressed CMint account to a destination CToken account with decimals validation, fully compatible with SPL Token MintToChecked semantics. Uses pinocchio-token-program to process the mint_to_checked operation which handles balance/supply updates, authority validation, frozen account checks, and decimals validation. After minting, automatically tops up compressible accounts with additional lamports if needed to prevent accounts from becoming compressible during normal operations. Both CMint and destination CToken can receive top-ups based on their current slot and account balance. Supports max_top_up parameter to limit rent top-up costs where 0 means no limit. @@ -13,7 +13,7 @@ Account layouts: - `CompressionInfo` extension defined in: program-libs/compressible/src/compression_info.rs **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_mint_to.rs (lines 62-112, function `process_ctoken_mint_to_checked`) +Path: programs/compressed-token/program/src/ctoken/mint_to.rs (lines 62-112, function `process_ctoken_mint_to_checked`) Byte layout: - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to mint diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_REVOKE.md b/programs/compressed-token/program/docs/ctoken/REVOKE.md similarity index 97% rename from programs/compressed-token/program/docs/instructions/CTOKEN_REVOKE.md rename to programs/compressed-token/program/docs/ctoken/REVOKE.md index da057bae30..e5fcd390ba 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_REVOKE.md +++ b/programs/compressed-token/program/docs/ctoken/REVOKE.md @@ -2,7 +2,7 @@ **discriminator:** 5 **enum:** `InstructionType::CTokenRevoke` -**path:** programs/compressed-token/program/src/ctoken_approve_revoke.rs +**path:** programs/compressed-token/program/src/ctoken/approve_revoke.rs ### SPL Instruction Format Compatibility @@ -18,7 +18,7 @@ If the CToken account has a compressible extension and requires a rent top-up, t Revokes any previously granted delegation on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). Before the revoke operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible revoke semantics. Supports backwards-compatible instruction data format (0 bytes legacy vs 2 bytes with max_top_up). The revoke operation follows SPL Token rules exactly (clears delegate and delegated_amount). **Instruction data:** -Path: programs/compressed-token/program/src/ctoken_approve_revoke.rs (lines 71-106) +Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 71-106) - Empty (0 bytes): legacy format, no max_top_up enforcement (max_top_up = 0, no limit) - Bytes 0-1 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit) diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_THAW_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CTOKEN_THAW_ACCOUNT.md rename to programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md index e5c4386f0a..0c35639fe4 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_THAW_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md @@ -2,7 +2,7 @@ **discriminator:** 11 **enum:** `InstructionType::CTokenThawAccount` -**path:** programs/compressed-token/program/src/ctoken_freeze_thaw.rs +**path:** programs/compressed-token/program/src/ctoken/freeze_thaw.rs **description:** Thaws a frozen decompressed ctoken account, restoring normal operation. This is a pass-through instruction that validates mint ownership (must be owned by SPL Token, Token-2022, or CToken program) before delegating to pinocchio-token-program for standard SPL Token thaw validation. After thawing, the account's state field is set to AccountState::Initialized, and only the freeze_authority of the mint can thaw accounts (mint must have freeze_authority set). The account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER.md b/programs/compressed-token/program/docs/ctoken/TRANSFER.md similarity index 98% rename from programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER.md rename to programs/compressed-token/program/docs/ctoken/TRANSFER.md index 5e4f95fc93..17e787aa71 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER.md +++ b/programs/compressed-token/program/docs/ctoken/TRANSFER.md @@ -2,7 +2,7 @@ **discriminator:** 3 **enum:** `InstructionType::CTokenTransfer` -**path:** programs/compressed-token/program/src/transfer/default.rs +**path:** programs/compressed-token/program/src/ctoken/transfer/default.rs ### SPL Instruction Format Compatibility diff --git a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER_CHECKED.md b/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md similarity index 99% rename from programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER_CHECKED.md rename to programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md index e532e9f375..73046bd193 100644 --- a/programs/compressed-token/program/docs/instructions/CTOKEN_TRANSFER_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md @@ -2,7 +2,7 @@ **discriminator:** 12 **enum:** `InstructionType::CTokenTransferChecked` -**path:** programs/compressed-token/program/src/transfer/checked.rs +**path:** programs/compressed-token/program/src/ctoken/transfer/checked.rs ### SPL Instruction Format Compatibility From 1917f521a691880166836cf63460ececaf97b386 Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 6 Jan 2026 00:31:38 +0000 Subject: [PATCH 04/38] fix: create ctoken account --- .../instructions/extensions/compressible.rs | 5 +- .../tests/ctoken/create.rs | 241 ++++++++++++ .../tests/ctoken/create_ata.rs | 197 ++++++++++ programs/compressed-token/anchor/src/lib.rs | 306 +++++++-------- .../program/docs/ctoken/CREATE.md | 63 ++- .../mint_action/actions/decompress_mint.rs | 2 +- .../program/src/compressible/claim.rs | 5 +- .../src/compressible/withdraw_funding_pool.rs | 2 +- .../program/src/ctoken/create.rs | 223 ++--------- .../program/src/ctoken/create_ata.rs | 91 +---- .../program/src/shared/config_account.rs | 44 +++ .../src/shared/initialize_ctoken_account.rs | 166 ++++++-- .../program/src/shared/mod.rs | 3 + .../program/tests/check_authority.rs | 2 +- .../program/tests/compress_and_close.rs | 2 +- .../compressed-token/program/tests/mint.rs | 4 +- .../program/tests/mint_action.rs | 2 +- .../program/tests/mint_validation.rs | 358 ++++++++++++++++++ .../program/tests/multi_sum_check.rs | 2 +- .../program/tests/queue_indices.rs | 2 +- 20 files changed, 1228 insertions(+), 492 deletions(-) create mode 100644 programs/compressed-token/program/src/shared/config_account.rs create mode 100644 programs/compressed-token/program/tests/mint_validation.rs diff --git a/program-libs/ctoken-interface/src/instructions/extensions/compressible.rs b/program-libs/ctoken-interface/src/instructions/extensions/compressible.rs index 1a6c92c67f..496006eb5a 100644 --- a/program-libs/ctoken-interface/src/instructions/extensions/compressible.rs +++ b/program-libs/ctoken-interface/src/instructions/extensions/compressible.rs @@ -18,8 +18,9 @@ pub struct CompressibleExtensionInstructionData { /// Rent payment in epochs. /// Paid once at initialization. pub rent_payment: u8, - /// Placeholder for future use. If true, the compressed token account cannot be transferred, - /// only decompressed. Currently unused - always set to 0. + /// If non-zero, the compressed token account cannot be transferred, only decompressed. + /// Required for mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook). + /// Must be set for compressible ATAs. pub compression_only: u8, pub write_top_up: u32, pub compress_to_account_pubkey: Option, diff --git a/program-tests/compressed-token-test/tests/ctoken/create.rs b/program-tests/compressed-token-test/tests/ctoken/create.rs index 19b5b180c4..6082cead8e 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create.rs @@ -89,6 +89,59 @@ async fn test_create_compressible_token_account_instruction() { create_and_assert_token_account(&mut context, compressible_data, "No lamports_per_write") .await; } + + // Test 6: Maximum prepaid epochs (255) - boundary test + { + context.token_account_keypair = Keypair::new(); + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: payer_pubkey, // Use payer as rent sponsor for large epoch payment + num_prepaid_epochs: 255, // Maximum u8 value + lamports_per_write: Some(100), + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + create_and_assert_token_account(&mut context, compressible_data, "max_prepaid_epochs_255") + .await; + } + + // Test 7: Exactly max_top_up for lamports_per_write - boundary test + { + context.token_account_keypair = Keypair::new(); + let max_top_up = RentConfig::default().max_top_up as u32; + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(max_top_up), // Exactly at limit (should succeed) + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + create_and_assert_token_account( + &mut context, + compressible_data, + "exactly_max_top_up_lamports_per_write", + ) + .await; + } + + // Test 8: Zero lamports_per_write - edge case + { + context.token_account_keypair = Keypair::new(); + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: context.rent_sponsor, + num_prepaid_epochs: 2, + lamports_per_write: Some(0), // Zero (should succeed) + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + create_and_assert_token_account(&mut context, compressible_data, "zero_lamports_per_write") + .await; + } } #[tokio::test] @@ -453,4 +506,192 @@ async fn test_create_compressible_token_account_failing() { // Should fail with MissingRequiredSignature (8) light_program_test::utils::assert::assert_rpc_error(result, 0, 8).unwrap(); } + + // Test 10: Non-compressible account for mint with restricted extensions + // Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) + // require the compression_only marker which is part of the Compressible extension. + // Error: 6115 (MissingCompressibleConfig) + { + use forester_utils::instructions::create_account::create_account_instruction; + use light_test_utils::mint_2022::create_mint_22_with_extension_types; + use solana_sdk::instruction::{AccountMeta, Instruction}; + use spl_token_2022::extension::ExtensionType; + + println!("Test 10: Non-compressible account for mint with restricted extensions"); + + // Create a Token-2022 mint with TransferFeeConfig (a restricted extension) + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, // decimals + &[ExtensionType::TransferFeeConfig], + ) + .await; + let mint_with_restricted_ext = mint_keypair.pubkey(); + + // Pre-allocate 200-byte token account owned by ctoken program + let token_account_keypair = Keypair::new(); + let token_account_pubkey = token_account_keypair.pubkey(); + let account_size = 200usize; + + let create_account_ix = create_account_instruction( + &payer_pubkey, + account_size, + context + .rpc + .get_minimum_balance_for_rent_exemption(account_size) + .await + .unwrap(), + &light_compressed_token::ID, + Some(&token_account_keypair), + ); + + context + .rpc + .create_and_send_transaction( + &[create_account_ix], + &payer_pubkey, + &[&context.payer, &token_account_keypair], + ) + .await + .unwrap(); + + // Build manual instruction for non-compressible path: + // - discriminator: 18 (InitializeAccount3) + // - data: just 32 bytes (owner pubkey) - triggers non-compressible path + // - accounts: token_account (mutable), mint (non-mutable) + let owner_pubkey = context.owner_keypair.pubkey(); + let mut instruction_data = vec![18u8]; // discriminator + instruction_data.extend_from_slice(&owner_pubkey.to_bytes()); // 32-byte owner + + let create_non_compressible_ix = Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(token_account_pubkey, false), // token_account (mutable, not signer) + AccountMeta::new_readonly(mint_with_restricted_ext, false), // mint + ], + data: instruction_data, + }; + + let result = context + .rpc + .create_and_send_transaction( + &[create_non_compressible_ix], + &payer_pubkey, + &[&context.payer], + ) + .await; + + // Should fail with MissingCompressibleConfig (6115) + // Rationale: Mints with restricted extensions must be marked as compression_only, + // and that marker is part of the Compressible extension + light_program_test::utils::assert::assert_rpc_error(result, 0, 6115).unwrap(); + } + + // Test 11: Token account passed as mint + // Passing a valid T22 token account instead of a mint should fail validation. + // The is_valid_mint function checks AccountType at offset 165 - token accounts have AccountType=2. + // Error: 3 (InstructionError::InvalidAccountData) + { + use light_test_utils::mint_2022::{ + create_mint_22_with_extension_types, create_token_22_account, + }; + use spl_token_2022::extension::ExtensionType; + + println!("Test 11: Token account passed as mint"); + + context.token_account_keypair = Keypair::new(); + + // Create a real T22 mint + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, + &[ExtensionType::TransferFeeConfig], + ) + .await; + let real_mint = mint_keypair.pubkey(); + + // Create a T22 token account for that mint + let t22_token_account = + create_token_22_account(&mut context.rpc, &context.payer, &real_mint, &payer_pubkey) + .await; + + // Try to create CToken with the token account as mint (should fail) + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compression_only: true, // Required for restricted extensions + }; + + let create_token_account_ix = CreateCTokenAccount::new( + payer_pubkey, + context.token_account_keypair.pubkey(), + t22_token_account, // Token account, not mint! + context.owner_keypair.pubkey(), + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + let result = context + .rpc + .create_and_send_transaction( + &[create_token_account_ix], + &payer_pubkey, + &[&context.payer, &context.token_account_keypair], + ) + .await; + + // Should fail with InstructionError::InvalidAccountData (3) because is_valid_mint returns false + // for token accounts (AccountType=2 at offset 165) + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); + } + + // Test 12: Invalid token_account_version + // Only version 3 (ShaFlat) is supported. V1 and V2 are rejected. + // Error: 2 (InstructionError::InvalidInstructionData) + { + println!("Test 12: Invalid token_account_version"); + + context.token_account_keypair = Keypair::new(); + + // Build instruction using SDK with V1 version (not supported for create) + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::V1, // Not supported! + compression_only: false, + }; + + let create_ix = CreateCTokenAccount::new( + payer_pubkey, + context.token_account_keypair.pubkey(), + context.mint_pubkey, + context.owner_keypair.pubkey(), + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + let result = context + .rpc + .create_and_send_transaction( + &[create_ix], + &payer_pubkey, + &[&context.payer, &context.token_account_keypair], + ) + .await; + + // Should fail with InstructionError::InvalidInstructionData (2) + // Only version 3 (ShaFlat) is supported for compressible accounts + light_program_test::utils::assert::assert_rpc_error(result, 0, 2).unwrap(); + } } 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 be001e0c6d..256d4eafe8 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create_ata.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create_ata.rs @@ -123,6 +123,78 @@ async fn test_create_compressible_ata() { ) .await; } + + // Test 6: Maximum prepaid epochs (255) - boundary test + { + // Use different mint for sixth ATA + context.mint_pubkey = solana_sdk::pubkey::Pubkey::new_unique(); + + let compressible_data = CompressibleData { + compression_authority: context.compression_authority, + rent_sponsor: payer_pubkey, // Use payer as rent sponsor for large epoch payment + num_prepaid_epochs: 255, // Maximum u8 value + lamports_per_write: Some(100), + account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compress_to_pubkey: false, + payer: payer_pubkey, + }; + + create_and_assert_ata( + &mut context, + Some(compressible_data), + false, // Non-idempotent + "max_prepaid_epochs_255", + ) + .await; + } + + // Test 7: Owner equals mint pubkey - edge case + // This is an unusual but valid configuration where the owner of the ATA + // is the same pubkey as the mint. Should succeed. + { + // Use a new unique pubkey that will serve as both owner and mint + let owner_and_mint = solana_sdk::pubkey::Pubkey::new_unique(); + context.mint_pubkey = owner_and_mint; + + // Temporarily change the owner keypair to use the same pubkey as mint + // Note: This is a degenerate case but should still work + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compression_only: true, + }; + + let create_ata_ix = CreateAssociatedCTokenAccount::new( + payer_pubkey, + owner_and_mint, // Owner == Mint + owner_and_mint, // Mint == Owner + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[create_ata_ix], &payer_pubkey, &[&context.payer]) + .await + .unwrap(); + + // Verify ATA was created at the expected address + let (expected_ata, _) = derive_ctoken_ata(&owner_and_mint, &owner_and_mint); + let account = context.rpc.get_account(expected_ata).await.unwrap(); + assert!( + account.is_some(), + "ATA with owner==mint should be created successfully" + ); + println!( + "Successfully created ATA with owner==mint at {}", + expected_ata + ); + } } #[tokio::test] @@ -699,6 +771,131 @@ async fn test_create_ata_failing() { // Solana runtime rejects this as unauthorized signer privilege escalation. light_program_test::utils::assert::assert_rpc_error(result, 0, 19).unwrap(); } + + // Test 11: Non-compressible ATA for mint with restricted extensions + // Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) + // require the compression_only marker which is part of the Compressible extension. + // Error: 6115 (MissingCompressibleConfig) + { + use anchor_lang::prelude::borsh::BorshSerialize; + use light_ctoken_interface::instructions::create_associated_token_account::CreateAssociatedTokenAccountInstructionData; + use light_test_utils::mint_2022::create_mint_22_with_extension_types; + use solana_sdk::instruction::Instruction; + use spl_token_2022::extension::ExtensionType; + + println!("Test 11: Non-compressible ATA for mint with restricted extensions"); + + // Create a Token-2022 mint with TransferFeeConfig (a restricted extension) + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, // decimals + &[ExtensionType::TransferFeeConfig], + ) + .await; + let mint_with_restricted_ext = mint_keypair.pubkey(); + + // Use a new owner for this test + let owner = solana_sdk::pubkey::Pubkey::new_unique(); + + // Derive ATA address + let (ata_pubkey, bump) = derive_ctoken_ata(&owner, &mint_with_restricted_ext); + + // Build instruction data with compressible_config: None (non-compressible) + let instruction_data = CreateAssociatedTokenAccountInstructionData { + bump, + compressible_config: None, // Non-compressible! + }; + + let mut data = vec![100]; // CreateAssociatedCTokenAccount discriminator + instruction_data.serialize(&mut data).unwrap(); + + // Account order: owner, mint, payer, ata, system_program + let ix = Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + solana_sdk::instruction::AccountMeta::new_readonly(owner, false), + solana_sdk::instruction::AccountMeta::new_readonly(mint_with_restricted_ext, false), + solana_sdk::instruction::AccountMeta::new(payer_pubkey, true), + solana_sdk::instruction::AccountMeta::new(ata_pubkey, false), + solana_sdk::instruction::AccountMeta::new_readonly( + solana_sdk::pubkey::Pubkey::default(), + false, + ), + ], + data, + }; + + let result = context + .rpc + .create_and_send_transaction(&[ix], &payer_pubkey, &[&context.payer]) + .await; + + // Should fail with MissingCompressibleConfig (6115) + // Rationale: Mints with restricted extensions must be marked as compression_only, + // and that marker is part of the Compressible extension + light_program_test::utils::assert::assert_rpc_error(result, 0, 6115).unwrap(); + } + + // Test 12: Token account passed as mint + // Passing a valid T22 token account instead of a mint should fail validation. + // The is_valid_mint function checks AccountType at offset 165 - token accounts have AccountType=2. + // Error: 3 (InstructionError::InvalidAccountData) + { + use light_test_utils::mint_2022::{ + create_mint_22_with_extension_types, create_token_22_account, + }; + use spl_token_2022::extension::ExtensionType; + + println!("Test 12: Token account passed as mint (ATA)"); + + // Create a real T22 mint + let (mint_keypair, _config) = create_mint_22_with_extension_types( + &mut context.rpc, + &context.payer, + 9, + &[ExtensionType::TransferFeeConfig], + ) + .await; + let real_mint = mint_keypair.pubkey(); + + // Create a T22 token account for that mint + let t22_token_account = + create_token_22_account(&mut context.rpc, &context.payer, &real_mint, &payer_pubkey) + .await; + + // Use a new owner for this test + let owner = solana_sdk::pubkey::Pubkey::new_unique(); + + // Try to create ATA with the token account as mint (should fail) + let compressible_params = CompressibleParams { + compressible_config: context.compressible_config, + rent_sponsor: context.rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: light_ctoken_interface::state::TokenDataVersion::ShaFlat, + compression_only: true, // Required for restricted extensions + }; + + let create_ata_ix = CreateAssociatedCTokenAccount::new( + payer_pubkey, + owner, + t22_token_account, // Token account, not mint! + ) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + let result = context + .rpc + .create_and_send_transaction(&[create_ata_ix], &payer_pubkey, &[&context.payer]) + .await; + + // Should fail with InstructionError::InvalidAccountData (3) because is_valid_mint returns false + // for token accounts (AccountType=2 at offset 165) + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); + } } #[tokio::test] diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index 0081f30cdf..80dd79fca5 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -249,305 +249,305 @@ pub mod light_compressed_token { #[error_code] pub enum ErrorCode { #[msg("public keys and amounts must be of same length")] - PublicKeyAmountMissmatch, + PublicKeyAmountMissmatch, // 6000 #[msg("ComputeInputSumFailed")] - ComputeInputSumFailed, + ComputeInputSumFailed, // 6001 #[msg("ComputeOutputSumFailed")] - ComputeOutputSumFailed, + ComputeOutputSumFailed, // 6002 #[msg("ComputeCompressSumFailed")] - ComputeCompressSumFailed, + ComputeCompressSumFailed, // 6003 #[msg("ComputeDecompressSumFailed")] - ComputeDecompressSumFailed, + ComputeDecompressSumFailed, // 6004 #[msg("SumCheckFailed")] - SumCheckFailed, + SumCheckFailed, // 6005 #[msg("DecompressRecipientUndefinedForDecompress")] - DecompressRecipientUndefinedForDecompress, + DecompressRecipientUndefinedForDecompress, // 6006 #[msg("CompressedPdaUndefinedForDecompress")] - CompressedPdaUndefinedForDecompress, + CompressedPdaUndefinedForDecompress, // 6007 #[msg("DeCompressAmountUndefinedForDecompress")] - DeCompressAmountUndefinedForDecompress, + DeCompressAmountUndefinedForDecompress, // 6008 #[msg("CompressedPdaUndefinedForCompress")] - CompressedPdaUndefinedForCompress, + CompressedPdaUndefinedForCompress, // 6009 #[msg("DeCompressAmountUndefinedForCompress")] - DeCompressAmountUndefinedForCompress, + DeCompressAmountUndefinedForCompress, // 6010 #[msg("DelegateSignerCheckFailed")] - DelegateSignerCheckFailed, + DelegateSignerCheckFailed, // 6011 #[msg("Minted amount greater than u64::MAX")] - MintTooLarge, + MintTooLarge, // 6012 #[msg("SplTokenSupplyMismatch")] - SplTokenSupplyMismatch, + SplTokenSupplyMismatch, // 6013 #[msg("HeapMemoryCheckFailed")] - HeapMemoryCheckFailed, + HeapMemoryCheckFailed, // 6014 #[msg("The instruction is not callable")] - InstructionNotCallable, + InstructionNotCallable, // 6015 #[msg("ArithmeticUnderflow")] - ArithmeticUnderflow, + ArithmeticUnderflow, // 6016 #[msg("HashToFieldError")] - HashToFieldError, + HashToFieldError, // 6017 #[msg("Expected the authority to be also a mint authority")] - InvalidAuthorityMint, + InvalidAuthorityMint, // 6018 #[msg("Provided authority is not the freeze authority")] - InvalidFreezeAuthority, - InvalidDelegateIndex, - TokenPoolPdaUndefined, + InvalidFreezeAuthority, // 6019 + InvalidDelegateIndex, // 6020 + TokenPoolPdaUndefined, // 6021 #[msg("Compress or decompress recipient is the same account as the token pool pda.")] - IsTokenPoolPda, - InvalidTokenPoolPda, - NoInputTokenAccountsProvided, - NoInputsProvided, - MintHasNoFreezeAuthority, - MintWithInvalidExtension, + IsTokenPoolPda, // 6022 + InvalidTokenPoolPda, // 6023 + NoInputTokenAccountsProvided, // 6024 + NoInputsProvided, // 6025 + MintHasNoFreezeAuthority, // 6026 + MintWithInvalidExtension, // 6027 #[msg("The token account balance is less than the remaining amount.")] - InsufficientTokenAccountBalance, + InsufficientTokenAccountBalance, // 6028 #[msg("Max number of token pools reached.")] - InvalidTokenPoolBump, - FailedToDecompress, - FailedToBurnSplTokensFromTokenPool, - NoMatchingBumpFound, - NoAmount, - AmountsAndAmountProvided, + InvalidTokenPoolBump, // 6029 + FailedToDecompress, // 6030 + FailedToBurnSplTokensFromTokenPool, // 6031 + NoMatchingBumpFound, // 6032 + NoAmount, // 6033 + AmountsAndAmountProvided, // 6034 #[msg("Cpi context set and set first is not usable with burn, compression(transfer ix) or decompress(transfer).")] - CpiContextSetNotUsable, - MintIsNone, - InvalidMintPda, + CpiContextSetNotUsable, // 6035 + MintIsNone, // 6036 + InvalidMintPda, // 6037 #[msg("Sum inputs mint indices not in ascending order.")] - InputsOutOfOrder, + InputsOutOfOrder, // 6038 #[msg("Sum check, too many mints (max 5).")] - TooManyMints, - InvalidExtensionType, - InstructionDataExpectedDelegate, - ZeroCopyExpectedDelegate, + TooManyMints, // 6039 + InvalidExtensionType, // 6040 + InstructionDataExpectedDelegate, // 6041 + ZeroCopyExpectedDelegate, // 6042 #[msg("Unsupported TLV extension type - only CompressedOnly is currently implemented")] - UnsupportedTlvExtensionType, + UnsupportedTlvExtensionType, // 6043 // Mint Action specific errors #[msg("Mint action requires at least one action")] - MintActionNoActionsProvided, + MintActionNoActionsProvided, // 6044 #[msg("Missing mint signer account for SPL mint creation")] - MintActionMissingSplMintSigner, + MintActionMissingSplMintSigner, // 6045 #[msg("Missing system account configuration for mint action")] - MintActionMissingSystemAccount, + MintActionMissingSystemAccount, // 6046 #[msg("Invalid mint bump seed provided")] - MintActionInvalidMintBump, + MintActionInvalidMintBump, // 6047 #[msg("Missing mint account for decompressed mint operations")] - MintActionMissingMintAccount, + MintActionMissingMintAccount, // 6048 #[msg("Missing token pool account for decompressed mint operations")] - MintActionMissingTokenPoolAccount, + MintActionMissingTokenPoolAccount, // 6049 #[msg("Missing token program for SPL operations")] - MintActionMissingTokenProgram, + MintActionMissingTokenProgram, // 6050 #[msg("Mint account does not match expected mint")] - MintAccountMismatch, + MintAccountMismatch, // 6051 #[msg("Invalid or missing authority for compression operation")] - InvalidCompressAuthority, + InvalidCompressAuthority, // 6052 #[msg("Invalid queue index configuration")] - MintActionInvalidQueueIndex, + MintActionInvalidQueueIndex, // 6053 #[msg("Mint output serialization failed")] - MintActionSerializationFailed, + MintActionSerializationFailed, // 6054 #[msg("Proof required for mint action but not provided")] - MintActionProofMissing, + MintActionProofMissing, // 6055 #[msg("Unsupported mint action type")] - MintActionUnsupportedActionType, + MintActionUnsupportedActionType, // 6056 #[msg("Metadata operations require decompressed mints")] - MintActionMetadataNotDecompressed, + MintActionMetadataNotDecompressed, // 6057 #[msg("Missing metadata extension in mint")] - MintActionMissingMetadataExtension, + MintActionMissingMetadataExtension, // 6058 #[msg("Extension index out of bounds")] - MintActionInvalidExtensionIndex, + MintActionInvalidExtensionIndex, // 6059 #[msg("Invalid metadata value encoding")] - MintActionInvalidMetadataValue, + MintActionInvalidMetadataValue, // 6060 #[msg("Invalid metadata key encoding")] - MintActionInvalidMetadataKey, + MintActionInvalidMetadataKey, // 6061 #[msg("Extension at index is not a TokenMetadata extension")] - MintActionInvalidExtensionType, + MintActionInvalidExtensionType, // 6062 #[msg("Metadata key not found")] - MintActionMetadataKeyNotFound, + MintActionMetadataKeyNotFound, // 6063 #[msg("Missing executing system accounts for mint action")] - MintActionMissingExecutingAccounts, + MintActionMissingExecutingAccounts, // 6064 #[msg("Invalid mint authority for mint action")] - MintActionInvalidMintAuthority, + MintActionInvalidMintAuthority, // 6065 #[msg("Invalid mint PDA derivation in mint action")] - MintActionInvalidMintPda, + MintActionInvalidMintPda, // 6066 #[msg("Missing system accounts for queue index calculation")] - MintActionMissingSystemAccountsForQueue, + MintActionMissingSystemAccountsForQueue, // 6067 #[msg("Account data serialization failed in mint output")] - MintActionOutputSerializationFailed, + MintActionOutputSerializationFailed, // 6068 #[msg("Mint amount too large, would cause overflow")] - MintActionAmountTooLarge, + MintActionAmountTooLarge, // 6069 #[msg("Initial supply must be 0 for new mint creation")] - MintActionInvalidInitialSupply, + MintActionInvalidInitialSupply, // 6070 #[msg("Mint version not supported")] - MintActionUnsupportedVersion, + MintActionUnsupportedVersion, // 6071 #[msg("New mint must start as compressed")] - MintActionInvalidCompressionState, - MintActionUnsupportedOperation, + MintActionInvalidCompressionState, // 6072 + MintActionUnsupportedOperation, // 6073 // Close account specific errors #[msg("Cannot close account with non-zero token balance")] - NonNativeHasBalance, + NonNativeHasBalance, // 6074 #[msg("Authority signature does not match expected owner")] - OwnerMismatch, + OwnerMismatch, // 6075 #[msg("Account is frozen and cannot perform this operation")] - AccountFrozen, + AccountFrozen, // 6076 // Account creation specific errors #[msg("Account size insufficient for token account")] - InsufficientAccountSize, + InsufficientAccountSize, // 6077 #[msg("Account already initialized")] - AlreadyInitialized, + AlreadyInitialized, // 6078 #[msg("Extension instruction data invalid")] - InvalidExtensionInstructionData, + InvalidExtensionInstructionData, // 6079 #[msg("Lamports amount too large")] - MintActionLamportsAmountTooLarge, + MintActionLamportsAmountTooLarge, // 6080 #[msg("Invalid token program provided")] - InvalidTokenProgram, + InvalidTokenProgram, // 6081 // Transfer2 specific errors #[msg("Cannot access system accounts for CPI context write operations")] - Transfer2CpiContextWriteInvalidAccess, + Transfer2CpiContextWriteInvalidAccess, // 6082 #[msg("SOL pool operations not supported with CPI context write")] - Transfer2CpiContextWriteWithSolPool, + Transfer2CpiContextWriteWithSolPool, // 6083 #[msg("Change account must not contain token data")] - Transfer2InvalidChangeAccountData, + Transfer2InvalidChangeAccountData, // 6084 #[msg("Cpi context expected but not provided.")] - CpiContextExpected, + CpiContextExpected, // 6085 #[msg("CPI accounts slice exceeds provided account infos")] - CpiAccountsSliceOutOfBounds, + CpiAccountsSliceOutOfBounds, // 6086 // CompressAndClose specific errors #[msg("CompressAndClose requires a destination account for rent lamports")] - CompressAndCloseDestinationMissing, + CompressAndCloseDestinationMissing, // 6087 #[msg("CompressAndClose requires an authority account")] - CompressAndCloseAuthorityMissing, + CompressAndCloseAuthorityMissing, // 6088 #[msg("CompressAndClose: Compressed token owner does not match expected owner")] - CompressAndCloseInvalidOwner, + CompressAndCloseInvalidOwner, // 6089 #[msg("CompressAndClose: Compression amount must match the full token balance")] - CompressAndCloseAmountMismatch, + CompressAndCloseAmountMismatch, // 6090 #[msg("CompressAndClose: Token account balance must match compressed output amount")] - CompressAndCloseBalanceMismatch, + CompressAndCloseBalanceMismatch, // 6091 #[msg("CompressAndClose: Compressed token must not have a delegate")] - CompressAndCloseDelegateNotAllowed, + CompressAndCloseDelegateNotAllowed, // 6092 #[msg("CompressAndClose: Invalid compressed token version")] - CompressAndCloseInvalidVersion, + CompressAndCloseInvalidVersion, // 6093 #[msg("InvalidAddressTree")] - InvalidAddressTree, + InvalidAddressTree, // 6094 #[msg("Too many compression transfers. Maximum 40 transfers allowed per instruction")] - TooManyCompressionTransfers, + TooManyCompressionTransfers, // 6095 #[msg("Missing fee payer for compressions-only operation")] - CompressionsOnlyMissingFeePayer, + CompressionsOnlyMissingFeePayer, // 6096 #[msg("Missing CPI authority PDA for compressions-only operation")] - CompressionsOnlyMissingCpiAuthority, + CompressionsOnlyMissingCpiAuthority, // 6097 #[msg("Cpi authority pda expected but not provided.")] - ExpectedCpiAuthority, + ExpectedCpiAuthority, // 6098 #[msg("InvalidRentSponsor")] - InvalidRentSponsor, - TooManyMintToRecipients, + InvalidRentSponsor, // 6099 + TooManyMintToRecipients, // 6100 #[msg("Prefunding for exactly 1 epoch is not allowed due to epoch boundary timing risk. Use 0 or 2+ epochs.")] - OneEpochPrefundingNotAllowed, + OneEpochPrefundingNotAllowed, // 6101 #[msg("Duplicate mint index detected in inputs, outputs, or compressions")] - DuplicateMint, + DuplicateMint, // 6102 #[msg("Invalid compressed mint address derivation")] - MintActionInvalidCompressedMintAddress, + MintActionInvalidCompressedMintAddress, // 6103 #[msg("Invalid CPI context for create mint operation")] - MintActionInvalidCpiContextForCreateMint, + MintActionInvalidCpiContextForCreateMint, // 6104 #[msg("Invalid address tree pubkey in CPI context")] - MintActionInvalidCpiContextAddressTreePubkey, + MintActionInvalidCpiContextAddressTreePubkey, // 6105 #[msg("CompressAndClose: Cannot use the same compressed output account for multiple closures")] - CompressAndCloseDuplicateOutput, + CompressAndCloseDuplicateOutput, // 6106 #[msg( "CompressAndClose by compression authority requires compressed token account in outputs" )] - CompressAndCloseOutputMissing, + CompressAndCloseOutputMissing, // 6107 // CMint (decompressed compressed mint) specific errors #[msg("Missing mint signer account for mint action")] - MintActionMissingMintSigner, + MintActionMissingMintSigner, // 6108 #[msg("Missing CMint account for decompress mint action")] - MintActionMissingCMintAccount, + MintActionMissingCMintAccount, // 6109 #[msg("CMint account already exists")] - CMintAlreadyExists, + CMintAlreadyExists, // 6110 #[msg("Invalid CMint account owner")] - InvalidCMintOwner, + InvalidCMintOwner, // 6111 #[msg("Failed to deserialize CMint account data")] - CMintDeserializationFailed, + CMintDeserializationFailed, // 6112 #[msg("Failed to resize CMint account")] - CMintResizeFailed, + CMintResizeFailed, // 6113 // CMint Compressibility errors #[msg("Invalid rent payment - must be >= 2 (CMint is always compressible)")] - InvalidRentPayment, + InvalidRentPayment, // 6114 #[msg("Missing compressible config account for CMint")] - MissingCompressibleConfig, + MissingCompressibleConfig, // 6115 #[msg("Missing rent sponsor account for CMint")] - MissingRentSponsor, + MissingRentSponsor, // 6116 #[msg("Rent payment exceeds max funded epochs")] - RentPaymentExceedsMax, + RentPaymentExceedsMax, // 6117 #[msg("Write top-up exceeds maximum allowed")] - WriteTopUpExceedsMaximum, + WriteTopUpExceedsMaximum, // 6118 #[msg("Failed to calculate CMint top-up amount")] - CMintTopUpCalculationFailed, + CMintTopUpCalculationFailed, // 6119 // CompressAndCloseCMint specific errors #[msg("CMint is not decompressed")] - CMintNotDecompressed, + CMintNotDecompressed, // 6120 #[msg("CMint is missing Compressible extension")] - CMintMissingCompressibleExtension, + CMintMissingCompressibleExtension, // 6121 #[msg("CMint is not compressible (rent not expired)")] - CMintNotCompressible, + CMintNotCompressible, // 6122 #[msg("Cannot combine DecompressMint and CompressAndCloseCMint in same instruction")] - CannotDecompressAndCloseInSameInstruction, + CannotDecompressAndCloseInSameInstruction, // 6123 #[msg("CMint account does not match compressed_mint.metadata.mint")] - InvalidCMintAccount, + InvalidCMintAccount, // 6124 #[msg("Mint data required in instruction when not decompressed")] - MintDataRequired, + MintDataRequired, // 6125 // Extension validation errors #[msg("Invalid mint account data")] - InvalidMint, + InvalidMint, // 6126 #[msg("Token operations blocked - mint is paused")] - MintPaused, + MintPaused, // 6127 #[msg("Mint account required for transfer when account has PausableAccount extension")] - MintRequiredForTransfer, + MintRequiredForTransfer, // 6128 #[msg("Non-zero transfer fees are not supported")] - NonZeroTransferFeeNotSupported, + NonZeroTransferFeeNotSupported, // 6129 #[msg("Transfer hooks with non-nil program_id are not supported")] - TransferHookNotSupported, + TransferHookNotSupported, // 6130 #[msg("Mint has extensions that require compression_only mode")] - CompressionOnlyRequired, + CompressionOnlyRequired, // 6131 #[msg("CompressAndClose: Compressed token mint does not match source token account mint")] - CompressAndCloseInvalidMint, + CompressAndCloseInvalidMint, // 6132 #[msg("CompressAndClose: Missing required CompressedOnly extension in output TLV")] - CompressAndCloseMissingCompressedOnlyExtension, + CompressAndCloseMissingCompressedOnlyExtension, // 6133 #[msg("CompressAndClose: CompressedOnly mint_account_index must be 0")] - CompressAndCloseInvalidMintAccountIndex, + CompressAndCloseInvalidMintAccountIndex, // 6134 #[msg( "CompressAndClose: Delegated amount mismatch between ctoken and CompressedOnly extension" )] - CompressAndCloseDelegatedAmountMismatch, + CompressAndCloseDelegatedAmountMismatch, // 6135 #[msg("CompressAndClose: Delegate mismatch between ctoken and compressed token output")] - CompressAndCloseInvalidDelegate, + CompressAndCloseInvalidDelegate, // 6136 #[msg("CompressAndClose: Withheld transfer fee mismatch")] - CompressAndCloseWithheldFeeMismatch, + CompressAndCloseWithheldFeeMismatch, // 6137 #[msg("CompressAndClose: Frozen state mismatch")] - CompressAndCloseFrozenMismatch, + CompressAndCloseFrozenMismatch, // 6138 #[msg("TLV extensions require version 3 (ShaFlat)")] - TlvRequiresVersion3, + TlvRequiresVersion3, // 6139 #[msg("CToken account has extensions that cannot be compressed. Only Compressible extension or no extensions allowed.")] - CTokenHasDisallowedExtensions, + CTokenHasDisallowedExtensions, // 6140 #[msg("CompressAndClose: rent_sponsor_is_signer flag does not match actual signer")] - RentSponsorIsSignerMismatch, + RentSponsorIsSignerMismatch, // 6141 #[msg("Mint has restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook, DefaultAccountState) must not create compressed token accounts.")] - MintHasRestrictedExtensions, + MintHasRestrictedExtensions, // 6142 #[msg("Decompress: CToken delegate does not match input compressed account delegate")] - DecompressDelegateMismatch, + DecompressDelegateMismatch, // 6143 #[msg("Mint cache capacity exceeded (max 5 unique mints)")] - MintCacheCapacityExceeded, + MintCacheCapacityExceeded, // 6144 #[msg("in_lamports field is not yet implemented")] - InLamportsUnimplemented, + InLamportsUnimplemented, // 6145 #[msg("out_lamports field is not yet implemented")] - OutLamportsUnimplemented, + OutLamportsUnimplemented, // 6146 #[msg("Mints with restricted extensions require compressible accounts")] - CompressibleRequired, + CompressibleRequired, // 6147 #[msg("CMint account not found")] - CMintNotFound, + CMintNotFound, // 6148 #[msg("CompressedOnly inputs must decompress to CToken account, not SPL token account")] - CompressedOnlyRequiresCTokenDecompress, + CompressedOnlyRequiresCTokenDecompress, // 6149 #[msg("Invalid token data version")] - InvalidTokenDataVersion, + InvalidTokenDataVersion, // 6150 #[msg("compression_only can only be set for mints with restricted extensions")] - CompressionOnlyNotAllowed, + CompressionOnlyNotAllowed, // 6151 #[msg("Associated token accounts must have compression_only set")] - AtaRequiresCompressionOnly, + AtaRequiresCompressionOnly, // 6152 } /// Anchor error code offset - error codes start at 6000 diff --git a/programs/compressed-token/program/docs/ctoken/CREATE.md b/programs/compressed-token/program/docs/ctoken/CREATE.md index 1f68f51fa2..4e6cccf017 100644 --- a/programs/compressed-token/program/docs/ctoken/CREATE.md +++ b/programs/compressed-token/program/docs/ctoken/CREATE.md @@ -1,15 +1,6 @@ # Instructions - -**Instruction Schema:** -1. every instruction description must include the sections: - - **path** path to instruction code in the program - - **description** highlevel description what the instruction does including accounts used and their state layout (paths to the code), usage flows what the instruction does - - **instruction_data** paths to code where instruction data structs are defined - - **Accounts** accounts in order including checks - - **Instruction logic and checks** - - **Errors** possible errors and description what causes these errors - +- This file documents create ctoken account and create associated ctoken account. ## 1. create ctoken account @@ -38,7 +29,7 @@ - `compressible_config`: Optional `CompressibleExtensionInstructionData` (None = non-compressible account) 2. Instruction data with compressible extension program-libs/ctoken-interface/src/instructions/extensions/compressible.rs - - `token_account_version`: Version of the compressed token account hashing scheme (u8) + - `token_account_version`: Version of the compressed token account hashing scheme (u8). Must be 3 (ShaFlat) - only version 3 is supported. - `rent_payment`: Number of epochs to prepay for rent (u8) - `rent_payment = 1` is explicitly forbidden to prevent epoch boundary timing edge case (its rent for the current rent epoch) - Allowed values: 0 (no prefunding) or 2+ epochs (safe buffer) @@ -95,6 +86,9 @@ 4.3. Validate compression_only requirement for restricted extensions: - If mint has restricted extensions (e.g., TransferFee) and compression_only == 0 - Error: `ErrorCode::CompressionOnlyRequired` + 4.3.1. Validate compression_only is only set for mints with restricted extensions: + - If compression_only != 0 and mint has no restricted extensions + - Error: `ErrorCode::CompressionOnlyNotAllowed` 4.4. Calculate account size based on mint extensions (includes Compressible extension) 4.5. Calculate rent (rent exemption + prepaid epochs rent + compression incentive) 4.6. Check whether rent_payer is custom fee payer (rent_payer != config.rent_sponsor) @@ -109,6 +103,21 @@ - If custom fee payer, set custom fee payer as ctoken account rent_sponsor - Else set config.rent_sponsor as ctoken account rent_sponsor - Set `last_claimed_slot` to current slot (tracks when rent was last claimed/initialized) + - Validate token_account_version == 3 (ShaFlat) + - Error: `ProgramError::InvalidInstructionData` if version != 3 + - Validate write_top_up <= config.rent_config.max_top_up + - Error: `CTokenError::WriteTopUpExceedsMaximum` if exceeded + - Validate mint account (if initialized): + - Check mint owner is SPL Token, Token-2022, or CToken program + - Error: `ProgramError::IncorrectProgramId` if invalid owner + - Check mint structure is valid (82 bytes for SPL, or has AccountType marker for T22) + - Error: `ProgramError::InvalidAccountData` if invalid structure + - Cache decimals from mint account in extension + 5. If without compressible account (non-compressible path): + 5.1. Validate mint does not have restricted extensions + - Check: `!mint_extensions.has_restricted_extensions()` + - Error: `ErrorCode::MissingCompressibleConfig` if mint has restricted extensions + - Rationale: Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) must be marked as compression_only, and that marker is part of the Compressible extension **Errors:** - `ProgramError::BorshIoError` (error code: 15) - Failed to deserialize CreateTokenAccountInstructionData from instruction_data bytes @@ -116,15 +125,19 @@ - `AccountError::InvalidSigner` (error code: 12015) - token_account or payer is not a signer when required - `AccountError::AccountNotMutable` (error code: 12008) - token_account or payer is not mutable when required - `AccountError::AccountOwnedByWrongProgram` (error code: 12007) - Config account not owned by LightRegistry program - - `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig pod deserialization fails or compress_to_pubkey.check_seeds() fails - - `ProgramError::InvalidInstructionData` (error code: 3) - compressible_config is None in instruction data when compressible accounts provided, or extension data invalid + - `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig pod deserialization fails, compress_to_pubkey.check_seeds() fails, or invalid mint structure + - `ProgramError::InvalidInstructionData` (error code: 3) - compressible_config is None in instruction data when compressible accounts provided, extension data invalid, or token_account_version != 3 - `ProgramError::MissingRequiredSignature` (error code: 8) - Custom rent_payer is not a signer - `ProgramError::UnsupportedSysvar` (error code: 17) - Failed to get Clock sysvar + - `ProgramError::IncorrectProgramId` (error code: 1) - Mint account owner is not SPL Token, Token-2022, or CToken program - `CompressibleError::InvalidState` (error code: 19002) - CompressibleConfig is not in active state - `ErrorCode::InsufficientAccountSize` (error code: 6077) - token_account data length < 165 bytes (non-compressible) or < COMPRESSIBLE_TOKEN_ACCOUNT_SIZE (compressible) - - `ErrorCode::InvalidCompressAuthority` (error code: 6052) - compressible_config is Some but compressible_config_account is None during extension initialization - - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6116) - rent_payment is exactly 1 epoch, which is forbidden due to epoch boundary timing edge case + - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6101) - rent_payment is exactly 1 epoch, which is forbidden due to epoch boundary timing edge case - `ErrorCode::CompressionOnlyRequired` (error code: 6131) - Mint has restricted extensions (e.g., TransferFee) but compression_only is not set in instruction data + - `ErrorCode::MissingCompressibleConfig` (error code: 6115) - Either: (1) compressible_config is Some in instruction data but compressible accounts are missing, or (2) non-compressible account creation attempted for mint with restricted extensions + - `ErrorCode::CompressionOnlyNotAllowed` (error code: 6151) - compression_only is set but mint has no restricted extensions + - `CTokenError::WriteTopUpExceedsMaximum` (error code: 18042) - write_top_up exceeds config.rent_config.max_top_up + - `CTokenError::MissingCompressibleExtension` (error code: 18056) - Compressible extension initialization failed internally ## 2. create associated ctoken account @@ -140,11 +153,14 @@ 4. Associated token accounts cannot use compress_to_pubkey (always compress to owner) 5. Owner and mint are provided as accounts, bump is provided via instruction data 6. Token account must be uninitialized (owned by system program) unless idempotent mode + 7. ATAs for mints with restricted extensions must be compressible (the compression_only marker is part of the Compressible extension) **Instruction data:** 1. instruction data is defined in path: program-libs/ctoken-interface/src/instructions/create_associated_token_account.rs - `bump`: PDA bump seed for derivation (u8) - - `compressible_config`: Optional `CompressibleExtensionInstructionData`, same as create ctoken account but compress_to_account_pubkey must be None + - `compressible_config`: Optional `CompressibleExtensionInstructionData`, same as create ctoken account but: + - `compress_to_account_pubkey` must be None (ATAs always compress to owner) + - `compression_only` must be non-zero (compressible ATAs require compression_only) **Accounts:** 1. owner @@ -181,11 +197,14 @@ 4. Verify account is system-owned (uninitialized) - Error: `ProgramError::IllegalOwner` if not owned by system program 5. If compressible: - - Validate rent_payment is not exactly 1 epoch (same as create ctoken account step 3.0) + - Validate rent_payment is not exactly 1 epoch (same as create ctoken account step 4.1) - Check: `compressible_config.rent_payment != 1` - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails - Reject if compress_to_account_pubkey is Some (not allowed for ATAs) - Error: `ProgramError::InvalidInstructionData` if compress_to_account_pubkey is Some + - Validate compression_only is set (required for compressible ATAs) + - Check: `compressible_config.compression_only != 0` + - Error: `ErrorCode::AtaRequiresCompressionOnly` if compression_only == 0 - Parse additional accounts: config, rent_payer - Validate CompressibleConfig is active (not inactive or deprecated) - Calculate account size based on mint extensions (includes Compressible extension) @@ -198,7 +217,11 @@ - Create ATA PDA with rent_sponsor PDA paying rent exemption - Transfer compression incentive from fee_payer to account via CPI 6. If not compressible: - - Create ATA PDA with fee_payer paying rent exemption (base 165-byte SPL layout) + 6.1. Validate mint does not have restricted extensions + - Check: `!mint_extensions.has_restricted_extensions()` + - Error: `ErrorCode::MissingCompressibleConfig` if mint has restricted extensions + - Rationale: Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) must be marked as compression_only, and that marker is part of the Compressible extension + 6.2. Create ATA PDA with fee_payer paying rent exemption (base 165-byte SPL layout) 7. Initialize token account with is_ata flag set (same as ## 1. create ctoken account step 3.6, but with is_ata=true) **Errors:** @@ -208,4 +231,6 @@ - `ProgramError::MissingRequiredSignature` (error code: 8) - Custom rent_payer is not a signer - `AccountError::InvalidSigner` (error code: 12015) - fee_payer is not a signer - `AccountError::AccountNotMutable` (error code: 12008) - fee_payer or associated_token_account is not mutable - - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6116) - rent_payment is exactly 1 epoch (see create ctoken account errors) + - `ErrorCode::OneEpochPrefundingNotAllowed` (error code: 6101) - rent_payment is exactly 1 epoch (see create ctoken account errors) + - `ErrorCode::AtaRequiresCompressionOnly` (error code: 6152) - compressible ATA must have compression_only set (compression_only == 0 is not allowed) + - `ErrorCode::MissingCompressibleConfig` (error code: 6115) - non-compressible ATA creation attempted for mint with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index db3261f79a..2a3838b7bf 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -15,10 +15,10 @@ use spl_pod::solana_msg::msg; use crate::{ compressed_token::mint_action::accounts::MintActionAccounts, - ctoken::create::parse_config_account, shared::{ convert_program_error, create_pda_account::{create_pda_account, verify_pda}, + parse_config_account, }, }; diff --git a/programs/compressed-token/program/src/compressible/claim.rs b/programs/compressed-token/program/src/compressible/claim.rs index b5129926d9..1518884ff5 100644 --- a/programs/compressed-token/program/src/compressible/claim.rs +++ b/programs/compressed-token/program/src/compressible/claim.rs @@ -10,10 +10,7 @@ use light_program_profiler::profile; use pinocchio::{account_info::AccountInfo, sysvars::Sysvar}; use spl_pod::solana_msg::msg; -use crate::{ - ctoken::create::parse_config_account, - shared::{convert_program_error, transfer_lamports}, -}; +use crate::shared::{convert_program_error, parse_config_account, transfer_lamports}; /// Accounts required for the claim instruction pub struct ClaimAccounts<'a> { diff --git a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs index 8cefc06d08..30c47200a3 100644 --- a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs +++ b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs @@ -8,7 +8,7 @@ use pinocchio::{ use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; -use crate::ctoken::create::parse_config_account; +use crate::shared::parse_config_account; /// Accounts required for the withdraw funding pool instruction pub struct WithdrawFundingPoolAccounts<'a> { diff --git a/programs/compressed-token/program/src/ctoken/create.rs b/programs/compressed-token/program/src/ctoken/create.rs index 54a33df1d3..6bd6a3d473 100644 --- a/programs/compressed-token/program/src/ctoken/create.rs +++ b/programs/compressed-token/program/src/ctoken/create.rs @@ -1,132 +1,27 @@ -use anchor_lang::{prelude::ProgramError, pubkey}; +use anchor_lang::prelude::ProgramError; use borsh::BorshDeserialize; -use light_account_checks::{ - checks::{check_discriminator, check_owner}, - AccountIterator, -}; -use light_compressible::config::CompressibleConfig; +use light_account_checks::AccountIterator; +use light_compressed_account::Pubkey; use light_ctoken_interface::instructions::create_ctoken_account::CreateTokenAccountInstructionData; use light_program_profiler::profile; -use pinocchio::{account_info::AccountInfo, instruction::Seed}; -use spl_pod::{bytemuck, solana_msg::msg}; +use pinocchio::account_info::AccountInfo; +use spl_pod::solana_msg::msg; use crate::{ extensions::has_mint_extensions, shared::{ - convert_program_error, create_pda_account, + create_compressible_account, initialize_ctoken_account::{initialize_ctoken_account, CTokenInitConfig}, - transfer_lamports_via_cpi, + next_config_account, }, }; -/// Validated accounts for the create token account instruction -pub struct CreateCTokenAccounts<'info> { - /// The token account being created (signer, mutable) - pub token_account: &'info AccountInfo, - /// The mint for the token account (only used for pubkey not checked) - pub mint: &'info AccountInfo, - /// Optional compressible configuration accounts (None = non-compressible account) - pub compressible: Option>, -} - -/// Accounts required when creating a compressible token account -pub struct CompressibleAccounts<'info> { - /// Pays for the compression incentive rent when rent_payer is the rent recipient (signer, mutable) - pub payer: &'info AccountInfo, - /// Used for account creation CPI - pub system_program: &'info AccountInfo, - /// Either the rent recipient PDA or a custom fee payer - pub rent_payer: &'info AccountInfo, - /// Parsed configuration from the config account - pub parsed_config: &'info CompressibleConfig, -} - -impl<'info> CreateCTokenAccounts<'info> { - /// Parse and validate accounts from the provided account infos - #[profile] - #[inline(always)] - pub fn parse( - account_infos: &'info [AccountInfo], - is_compressible: bool, - ) -> Result { - let mut iter = AccountIterator::new(account_infos); - - // For compressible accounts: token_account must be signer (account created via CPI) - // For non-compressible accounts: token_account doesn't need to be signer (SPL compatibility) - let token_account = if is_compressible { - iter.next_signer_mut("token_account")? - } else { - iter.next_mut("token_account")? - }; - let mint = iter.next_non_mut("mint")?; - - // Parse optional compressible accounts - let compressible = if is_compressible { - Some(CompressibleAccounts { - payer: iter.next_signer_mut("payer")?, - parsed_config: next_config_account(&mut iter)?, - system_program: iter.next_non_mut("system program")?, - // Must be signer if custom rent payer. - // Rent sponsor is not signer. - rent_payer: iter.next_mut("rent payer")?, - }) - } else { - None - }; - - Ok(Self { - token_account, - mint, - compressible, - }) - } -} - -#[profile] -#[inline(always)] -pub fn parse_config_account( - config_account: &AccountInfo, -) -> Result<&CompressibleConfig, ProgramError> { - // Validate config account owner - check_owner( - &pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX").to_bytes(), - config_account, - )?; - // Parse config data - let data = unsafe { config_account.borrow_data_unchecked() }; - check_discriminator::(data)?; - let config = bytemuck::pod_from_bytes::(&data[8..]).map_err(|e| { - msg!("Failed to deserialize CompressibleConfig: {:?}", e); - ProgramError::InvalidAccountData - })?; - - Ok(config) -} - -#[profile] -#[inline(always)] -pub fn next_config_account<'info>( - iter: &mut AccountIterator<'info, AccountInfo>, -) -> Result<&'info CompressibleConfig, ProgramError> { - let config_account = iter.next_non_mut("compressible config")?; - let config = parse_config_account(config_account)?; - - // Validate config is active (only active allowed for account creation) - config.validate_active().map_err(ProgramError::from)?; - - Ok(config) -} - /// Process the create token account instruction #[profile] pub fn process_create_token_account( account_infos: &[AccountInfo], mut instruction_data: &[u8], ) -> Result<(), ProgramError> { - use light_compressed_account::Pubkey; - - use crate::shared::initialize_ctoken_account::CompressibleInitData; - // SPL compatibility: if instruction_data is exactly 32 bytes, treat as owner-only (no compressible config) // This matches SPL Token's initialize_account3 which only sends the owner pubkey let inputs = if instruction_data.len() == 32 { @@ -145,27 +40,29 @@ pub fn process_create_token_account( let is_compressible = inputs.compressible_config.is_some(); // Parse and validate accounts - let accounts = CreateCTokenAccounts::parse(account_infos, is_compressible)?; + let mut iter = AccountIterator::new(account_infos); + + // For compressible accounts: token_account must be signer (account created via CPI) + // For non-compressible accounts: token_account doesn't need to be signer (SPL compatibility) + let token_account = if is_compressible { + iter.next_signer_mut("token_account")? + } else { + iter.next_mut("token_account")? + }; + let mint = iter.next_non_mut("mint")?; // Check which extensions the mint has (single deserialization) - let mint_extensions = has_mint_extensions(accounts.mint)?; + let mint_extensions = has_mint_extensions(mint)?; // Handle compressible vs non-compressible account creation let compressible_init_data = if let Some(ref compressible_config) = inputs.compressible_config { - let compressible = accounts - .compressible - .as_ref() - .ok_or(ProgramError::InvalidAccountData)?; - - // Validate that rent_payment is not exactly 1 epoch (footgun prevention) - if compressible_config.rent_payment == 1 { - msg!("Prefunding for exactly 1 epoch is not allowed. If the account is created near an epoch boundary, it could become immediately compressible. Use 0 or 2+ epochs."); - return Err(anchor_compressed_token::ErrorCode::OneEpochPrefundingNotAllowed.into()); - } + let payer = iter.next_signer_mut("payer")?; + let config_account = next_config_account(&mut iter)?; + let _system_program = iter.next_non_mut("system_program")?; + let rent_payer = iter.next_mut("rent_payer")?; if let Some(compress_to_pubkey) = compressible_config.compress_to_account_pubkey.as_ref() { - // Compress to pubkey specifies compression to account pubkey instead of the owner. - compress_to_pubkey.check_seeds(accounts.token_account.key())?; + compress_to_pubkey.check_seeds(token_account.key())?; } // If restricted extensions exist, compression_only must be set @@ -182,67 +79,16 @@ pub fn process_create_token_account( return Err(anchor_compressed_token::ErrorCode::CompressionOnlyNotAllowed.into()); } - // Calculate account size based on extensions (includes Compressible extension) - let account_size = mint_extensions.calculate_account_size(true)?; - - let config_account = compressible.parsed_config; - let rent = config_account - .rent_config - .get_rent_with_compression_cost(account_size, compressible_config.rent_payment as u64); - let account_size = account_size as usize; - - let custom_rent_payer = - *compressible.rent_payer.key() != config_account.rent_sponsor.to_bytes(); - - // Prevents setting executable accounts as rent_sponsor - if custom_rent_payer && !compressible.rent_payer.is_signer() { - msg!("Custom rent payer must be a signer"); - return Err(ProgramError::MissingRequiredSignature); - } - - // Build fee_payer seeds (rent_sponsor PDA or None for custom keypair) - let version_bytes = config_account.version.to_le_bytes(); - let bump_seed = [config_account.rent_sponsor_bump]; - let rent_sponsor_seeds = [ - Seed::from(b"rent_sponsor".as_ref()), - Seed::from(version_bytes.as_ref()), - Seed::from(bump_seed.as_ref()), - ]; - - let fee_payer_seeds = if custom_rent_payer { - None - } else { - Some(rent_sponsor_seeds.as_slice()) - }; - - let additional_lamports = if custom_rent_payer { Some(rent) } else { None }; - - // Create token account (handles DoS prevention internally) - create_pda_account( - compressible.rent_payer, - accounts.token_account, - account_size, - fee_payer_seeds, + Some(create_compressible_account( + compressible_config, + &mint_extensions, + config_account, + rent_payer, + token_account, + payer, None, // token_account is keypair signer - additional_lamports, - )?; - - // When using protocol rent sponsor, payer pays the compression incentive - if !custom_rent_payer { - transfer_lamports_via_cpi(rent, compressible.payer, accounts.token_account) - .map_err(convert_program_error)?; - } - - Some(CompressibleInitData { - ix_data: compressible_config, - config_account: compressible.parsed_config, - custom_rent_payer: if custom_rent_payer { - Some(*compressible.rent_payer.key()) - } else { - None - }, - is_ata: false, - }) + false, + )?) } else { // Non-compressible account: token_account must already exist and be owned by our program // This is SPL-compatible initialize_account3 behavior @@ -251,13 +97,12 @@ pub fn process_create_token_account( // Initialize the token account initialize_ctoken_account( - accounts.token_account, + token_account, CTokenInitConfig { - mint: accounts.mint.key(), owner: &inputs.owner.to_bytes(), compressible: compressible_init_data, mint_extensions, - mint_account: accounts.mint, + mint_account: mint, }, ) } diff --git a/programs/compressed-token/program/src/ctoken/create_ata.rs b/programs/compressed-token/program/src/ctoken/create_ata.rs index 4811d6eb4e..c5ce1dc004 100644 --- a/programs/compressed-token/program/src/ctoken/create_ata.rs +++ b/programs/compressed-token/program/src/ctoken/create_ata.rs @@ -6,15 +6,12 @@ use light_program_profiler::profile; use pinocchio::{account_info::AccountInfo, instruction::Seed}; use spl_pod::solana_msg::msg; -use super::create::next_config_account; use crate::{ extensions::has_mint_extensions, shared::{ - convert_program_error, create_pda_account, - initialize_ctoken_account::{ - initialize_ctoken_account, CTokenInitConfig, CompressibleInitData, - }, - transfer_lamports_via_cpi, validate_ata_derivation, + create_compressible_account, create_pda_account, + initialize_ctoken_account::{initialize_ctoken_account, CTokenInitConfig}, + next_config_account, validate_ata_derivation, }, }; @@ -42,7 +39,8 @@ pub fn process_create_associated_token_account_idempotent( /// 2. fee_payer (signer, mut) /// 3. associated_token_account (mut) /// 4. system_program -/// Optional (only when compressible_config is Some): +/// +/// Optional (only when compressible_config is Some): /// 5. compressible_config /// 6. rent_payer #[profile] @@ -92,90 +90,28 @@ fn process_create_associated_token_account_with_mode( // Handle compressible vs non-compressible account creation let compressible = if let Some(compressible_config) = &inputs.compressible_config { - // Validate that rent_payment is not exactly 1 epoch (footgun prevention) - if compressible_config.rent_payment == 1 { - msg!("Prefunding for exactly 1 epoch is not allowed. If the account is created near an epoch boundary, it could become immediately compressible. Use 0 or 2+ epochs."); - return Err(anchor_compressed_token::ErrorCode::OneEpochPrefundingNotAllowed.into()); - } - - // Associated token accounts must not compress to pubkey if compressible_config.compress_to_account_pubkey.is_some() { msg!("Associated token accounts must not compress to pubkey"); return Err(ProgramError::InvalidInstructionData); } - - // Associated token accounts must always be compression_only if compressible_config.compression_only == 0 { msg!("Associated token accounts must have compression_only set"); return Err(anchor_compressed_token::ErrorCode::AtaRequiresCompressionOnly.into()); } - // Parse additional accounts for compressible path let config_account = next_config_account(&mut iter)?; let rent_payer = iter.next_mut("rent_payer")?; - // Calculate account size based on extensions (includes Compressible extension) - let account_size = mint_extensions.calculate_account_size(true)?; - - let rent = config_account - .rent_config - .get_rent_with_compression_cost(account_size, compressible_config.rent_payment as u64); - let account_size = account_size as usize; - - let custom_rent_payer = *rent_payer.key() != config_account.rent_sponsor.to_bytes(); - - // Prevents setting executable accounts as rent_sponsor - if custom_rent_payer && !rent_payer.is_signer() { - msg!("Custom rent payer must be a signer"); - return Err(ProgramError::MissingRequiredSignature); - } - - // Build rent sponsor seeds if using rent sponsor PDA as fee_payer - let version_bytes = config_account.version.to_le_bytes(); - let rent_sponsor_bump = [config_account.rent_sponsor_bump]; - let rent_sponsor_seeds = [ - Seed::from(b"rent_sponsor".as_ref()), - Seed::from(version_bytes.as_ref()), - Seed::from(rent_sponsor_bump.as_ref()), - ]; - - let fee_payer_seeds = if custom_rent_payer { - None - } else { - Some(rent_sponsor_seeds.as_slice()) - }; - - let additional_lamports = if custom_rent_payer { Some(rent) } else { None }; - - // Create ATA account - create_pda_account( + Some(create_compressible_account( + compressible_config, + &mint_extensions, + config_account, rent_payer, associated_token_account, - account_size, - fee_payer_seeds, - Some(ata_seeds.as_slice()), - additional_lamports, - )?; - - // When using protocol rent sponsor, fee_payer pays the compression incentive - if !custom_rent_payer { - transfer_lamports_via_cpi(rent, fee_payer, associated_token_account) - .map_err(convert_program_error)?; - } - - // For ATAs, we use is_ata flag in the extension instead of compress_to_pubkey. - // The is_ata flag allows decompress to verify the destination is the correct ATA - // while keeping the compressed account owner as the wallet owner (who can sign). - Some(CompressibleInitData { - ix_data: compressible_config, - config_account, - custom_rent_payer: if custom_rent_payer { - Some(*rent_payer.key()) - } else { - None - }, - is_ata: true, - }) + fee_payer, + Some(ata_seeds.as_slice()), // ATA is a PDA + true, // is_ata = true + )?) } else { // Non-compressible path: fee_payer pays for account creation directly // Non-compressible accounts have no extensions (base 165-byte SPL layout) @@ -197,7 +133,6 @@ fn process_create_associated_token_account_with_mode( initialize_ctoken_account( associated_token_account, CTokenInitConfig { - mint: mint_bytes, owner: owner_bytes, compressible, mint_extensions, diff --git a/programs/compressed-token/program/src/shared/config_account.rs b/programs/compressed-token/program/src/shared/config_account.rs new file mode 100644 index 0000000000..d29154d982 --- /dev/null +++ b/programs/compressed-token/program/src/shared/config_account.rs @@ -0,0 +1,44 @@ +use anchor_lang::{prelude::ProgramError, pubkey}; +use light_account_checks::{ + checks::{check_discriminator, check_owner}, + AccountIterator, +}; +use light_compressible::config::CompressibleConfig; +use light_program_profiler::profile; +use pinocchio::account_info::AccountInfo; +use spl_pod::{bytemuck, solana_msg::msg}; + +#[profile] +#[inline(always)] +pub fn parse_config_account( + config_account: &AccountInfo, +) -> Result<&CompressibleConfig, ProgramError> { + // Validate config account owner + check_owner( + &pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX").to_bytes(), + config_account, + )?; + // Parse config data + let data = unsafe { config_account.borrow_data_unchecked() }; + check_discriminator::(data)?; + let config = bytemuck::pod_from_bytes::(&data[8..]).map_err(|e| { + msg!("Failed to deserialize CompressibleConfig: {:?}", e); + ProgramError::InvalidAccountData + })?; + + Ok(config) +} + +#[profile] +#[inline(always)] +pub fn next_config_account<'info>( + iter: &mut AccountIterator<'info, AccountInfo>, +) -> Result<&'info CompressibleConfig, ProgramError> { + let config_account = iter.next_non_mut("compressible config")?; + let config = parse_config_account(config_account)?; + + // Validate config is active (only active allowed for account creation) + config.validate_active().map_err(ProgramError::from)?; + + Ok(config) +} 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 78b079a6ac..162a6fd755 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -13,9 +13,12 @@ use light_program_profiler::profile; use light_zero_copy::traits::ZeroCopyNew; #[cfg(target_os = "solana")] use pinocchio::sysvars::{clock::Clock, Sysvar}; -use pinocchio::{account_info::AccountInfo, msg, pubkey::Pubkey}; +use pinocchio::{account_info::AccountInfo, instruction::Seed, msg, pubkey::Pubkey}; -use crate::extensions::MintExtensionFlags; +use crate::{ + extensions::MintExtensionFlags, + shared::{convert_program_error, create_pda_account, transfer_lamports_via_cpi}, +}; const SPL_TOKEN_ID: [u8; 32] = spl_token::ID.to_bytes(); const SPL_TOKEN_2022_ID: [u8; 32] = spl_token_2022::ID.to_bytes(); @@ -43,8 +46,6 @@ pub struct CompressibleInitData<'a> { /// Configuration for initializing a CToken account pub struct CTokenInitConfig<'a> { - /// The mint pubkey (32 bytes) - pub mint: &'a [u8; 32], /// The owner pubkey (32 bytes) pub owner: &'a [u8; 32], /// Compressible configuration (None = not compressible) @@ -55,6 +56,87 @@ pub struct CTokenInitConfig<'a> { pub mint_account: &'a AccountInfo, } +#[profile] +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn create_compressible_account<'info>( + compressible_config: &'info CompressibleExtensionInstructionData, + mint_extensions: &MintExtensionFlags, + config_account: &'info CompressibleConfig, + rent_payer: &'info AccountInfo, + target_account: &'info AccountInfo, + fee_payer: &'info AccountInfo, + account_seeds: Option<&[Seed]>, + is_ata: bool, +) -> Result, ProgramError> { + // Validate rent_payment != 1 (epoch boundary edge case) + if compressible_config.rent_payment == 1 { + msg!("Prefunding for exactly 1 epoch is not allowed. If the account is created near an epoch boundary, it could become immediately compressible. Use 0 or 2+ epochs."); + return Err(anchor_compressed_token::ErrorCode::OneEpochPrefundingNotAllowed.into()); + } + + // Calculate account size (includes Compressible extension) + let account_size = mint_extensions.calculate_account_size(true)?; + + // Calculate rent with compression cost + let rent = config_account + .rent_config + .get_rent_with_compression_cost(account_size, compressible_config.rent_payment as u64); + let account_size = account_size as usize; + + let custom_rent_payer = *rent_payer.key() != config_account.rent_sponsor.to_bytes(); + + // Custom rent payer must be a signer (prevents executable accounts as rent_sponsor) + if custom_rent_payer && !rent_payer.is_signer() { + msg!("Custom rent payer must be a signer"); + return Err(ProgramError::MissingRequiredSignature); + } + + // Build rent sponsor seeds for PDA signing + let version_bytes = config_account.version.to_le_bytes(); + let bump_seed = [config_account.rent_sponsor_bump]; + let rent_sponsor_seeds = [ + Seed::from(b"rent_sponsor".as_ref()), + Seed::from(version_bytes.as_ref()), + Seed::from(bump_seed.as_ref()), + ]; + + let fee_payer_seeds = if custom_rent_payer { + None + } else { + Some(rent_sponsor_seeds.as_slice()) + }; + + let additional_lamports = if custom_rent_payer { Some(rent) } else { None }; + + // Create the account + create_pda_account( + rent_payer, + target_account, + account_size, + fee_payer_seeds, + account_seeds, + additional_lamports, + )?; + + // When using protocol rent sponsor, fee_payer pays the compression incentive + if !custom_rent_payer { + transfer_lamports_via_cpi(rent, fee_payer, target_account) + .map_err(convert_program_error)?; + } + + Ok(CompressibleInitData { + ix_data: compressible_config, + config_account, + custom_rent_payer: if custom_rent_payer { + Some(*rent_payer.key()) + } else { + None + }, + is_ata, + }) +} + /// Initialize a token account using zero-copy with embedded CompressionInfo #[profile] pub fn initialize_ctoken_account( @@ -62,7 +144,6 @@ pub fn initialize_ctoken_account( config: CTokenInitConfig<'_>, ) -> Result<(), ProgramError> { let CTokenInitConfig { - mint, owner, compressible, mint_extensions, @@ -72,19 +153,6 @@ pub fn initialize_ctoken_account( // Build extensions Vec from boolean flags // +1 for potential Compressible extension let mut extensions = Vec::with_capacity(mint_extensions.num_extensions() + 1); - if mint_extensions.has_pausable { - extensions.push(ExtensionStructConfig::PausableAccount(())); - } - if mint_extensions.has_permanent_delegate { - extensions.push(ExtensionStructConfig::PermanentDelegateAccount(())); - } - if mint_extensions.has_transfer_fee { - extensions.push(ExtensionStructConfig::TransferFeeAccount(())); - } - if mint_extensions.has_transfer_hook { - extensions.push(ExtensionStructConfig::TransferHookAccount(())); - } - // Add Compressible extension if compression is enabled if compressible.is_some() { extensions.push(ExtensionStructConfig::Compressible( @@ -92,11 +160,26 @@ pub fn initialize_ctoken_account( info: CompressionInfoConfig { rent_config: () }, }, )); - } + if mint_extensions.has_pausable { + extensions.push(ExtensionStructConfig::PausableAccount(())); + } + if mint_extensions.has_permanent_delegate { + extensions.push(ExtensionStructConfig::PermanentDelegateAccount(())); + } + if mint_extensions.has_transfer_fee { + extensions.push(ExtensionStructConfig::TransferFeeAccount(())); + } + if mint_extensions.has_transfer_hook { + extensions.push(ExtensionStructConfig::TransferHookAccount(())); + } + } else if mint_extensions.has_restricted_extensions() { + // Mints with restricted extensions must have the compressible extension. + return Err(anchor_compressed_token::ErrorCode::MissingCompressibleConfig.into()); + } // Build the config for new_zero_copy let zc_config = CompressedTokenConfig { - mint: light_compressed_account::Pubkey::from(*mint), + mint: light_compressed_account::Pubkey::from(*mint_account.key()), owner: light_compressed_account::Pubkey::from(*owner), state: if mint_extensions.default_state_frozen { AccountState::Frozen as u8 @@ -218,32 +301,39 @@ fn configure_compression_info( // Only try to read decimals if mint has data (is initialized) if !mint_data.is_empty() { let owner = mint_account.owner(); + let decimals = mint_data.get(44); - // Validate mint account based on owner program - let is_valid_mint = if *owner == SPL_TOKEN_ID { - // SPL Token: mint must be exactly 82 bytes - mint_data.len() == SPL_MINT_LEN - } else if *owner == SPL_TOKEN_2022_ID || *owner == CTOKEN_PROGRAM_ID { - // Token-2022/CToken: Either exactly 82 bytes (no extensions) or - // check AccountType marker at offset 165 (with extensions) - // Layout with extensions: 82 bytes mint + 83 bytes padding + AccountType - mint_data.len() == SPL_MINT_LEN - || (mint_data.len() > T22_ACCOUNT_TYPE_OFFSET - && mint_data[T22_ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_MINT) - } else { - msg!("Invalid mint owner"); - return Err(ProgramError::IncorrectProgramId); - }; - - if !is_valid_mint { + if !is_valid_mint(owner, &mint_data)? { msg!("Invalid mint account: not a valid mint"); return Err(ProgramError::InvalidAccountData); } // Mint layout: decimals at byte 44 for all token programs // (mint_authority option: 36, supply: 8) = 44 - compressible_ext.set_decimals(Some(mint_data[44])); + compressible_ext.set_decimals(decimals.copied()); } Ok(()) } + +#[inline(always)] +pub fn is_valid_mint(owner: &Pubkey, mint_data: &[u8]) -> Result { + if *owner == SPL_TOKEN_ID { + // SPL Token: mint must be exactly 82 bytes + Ok(mint_data.len() == SPL_MINT_LEN) + } else if *owner == SPL_TOKEN_2022_ID { + // Token-2022: Either exactly 82 bytes (no extensions) or + // check AccountType marker at offset 165 (with extensions) + // Layout with extensions: 82 bytes mint + 83 bytes padding + AccountType + Ok(mint_data.len() == SPL_MINT_LEN + || (mint_data.len() > T22_ACCOUNT_TYPE_OFFSET + && mint_data[T22_ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_MINT)) + } else if *owner == CTOKEN_PROGRAM_ID { + // CToken: Always has extensions, must be >165 bytes with AccountType=Mint + Ok(mint_data.len() > T22_ACCOUNT_TYPE_OFFSET + && mint_data[T22_ACCOUNT_TYPE_OFFSET] == ACCOUNT_TYPE_MINT) + } else { + msg!("Invalid mint owner"); + Err(ProgramError::IncorrectProgramId) + } +} diff --git a/programs/compressed-token/program/src/shared/mod.rs b/programs/compressed-token/program/src/shared/mod.rs index 99368379a9..3f52465d75 100644 --- a/programs/compressed-token/program/src/shared/mod.rs +++ b/programs/compressed-token/program/src/shared/mod.rs @@ -1,5 +1,6 @@ pub mod accounts; pub mod compressible_top_up; +pub mod config_account; mod convert_program_error; pub mod cpi; pub mod cpi_bytes_size; @@ -12,8 +13,10 @@ pub mod token_output; pub mod transfer_lamports; pub mod validate_ata_derivation; +pub use config_account::{next_config_account, parse_config_account}; pub use convert_program_error::convert_program_error; pub use create_pda_account::{create_pda_account, verify_pda}; +pub use initialize_ctoken_account::create_compressible_account; pub use light_account_checks::AccountIterator; pub use mint_to_token_pool::mint_to_token_pool; pub use transfer_lamports::*; diff --git a/programs/compressed-token/program/tests/check_authority.rs b/programs/compressed-token/program/tests/check_authority.rs index 31e850eeb6..08e9f3624a 100644 --- a/programs/compressed-token/program/tests/check_authority.rs +++ b/programs/compressed-token/program/tests/check_authority.rs @@ -1,6 +1,6 @@ use anchor_compressed_token::ErrorCode; use light_account_checks::account_info::test_account_info::pinocchio::get_account_info; -use light_compressed_token::mint_action::check_authority; +use light_compressed_token::compressed_token::mint_action::check_authority; use pinocchio::pubkey::Pubkey; // Anchor custom error codes start at offset 6000 diff --git a/programs/compressed-token/program/tests/compress_and_close.rs b/programs/compressed-token/program/tests/compress_and_close.rs index 8f7464c508..80e2202973 100644 --- a/programs/compressed-token/program/tests/compress_and_close.rs +++ b/programs/compressed-token/program/tests/compress_and_close.rs @@ -4,7 +4,7 @@ use light_account_checks::{ account_info::test_account_info::pinocchio::get_account_info, packed_accounts::ProgramPackedAccounts, }; -use light_compressed_token::transfer2::{ +use light_compressed_token::compressed_token::transfer2::{ accounts::Transfer2Accounts, compression::ctoken::close_for_compress_and_close, }; use light_ctoken_interface::{ diff --git a/programs/compressed-token/program/tests/mint.rs b/programs/compressed-token/program/tests/mint.rs index 7ed1a09028..6cd0030a21 100644 --- a/programs/compressed-token/program/tests/mint.rs +++ b/programs/compressed-token/program/tests/mint.rs @@ -4,11 +4,11 @@ use light_compressed_account::{ Pubkey, }; use light_compressed_token::{ - constants::COMPRESSED_MINT_DISCRIMINATOR, - mint_action::{ + compressed_token::mint_action::{ accounts::AccountsConfig, mint_input::create_input_compressed_mint_account, zero_copy_config::get_zero_copy_configs, }, + constants::COMPRESSED_MINT_DISCRIMINATOR, }; use light_ctoken_interface::{ instructions::{ diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index 90d9736fb7..666b336c56 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -4,7 +4,7 @@ /// that the derived configuration matches expected values based on instruction content. use borsh::BorshSerialize; use light_compressed_account::{instruction_data::compressed_proof::CompressedProof, Pubkey}; -use light_compressed_token::mint_action::accounts::AccountsConfig; +use light_compressed_token::compressed_token::mint_action::accounts::AccountsConfig; use light_ctoken_interface::{ instructions::{ extensions::{token_metadata::TokenMetadataInstructionData, ExtensionInstructionData}, diff --git a/programs/compressed-token/program/tests/mint_validation.rs b/programs/compressed-token/program/tests/mint_validation.rs new file mode 100644 index 0000000000..28bb2c5f62 --- /dev/null +++ b/programs/compressed-token/program/tests/mint_validation.rs @@ -0,0 +1,358 @@ +use anchor_lang::prelude::ProgramError; +use light_compressed_token::shared::initialize_ctoken_account::is_valid_mint; +use pinocchio::pubkey::Pubkey; + +const SPL_TOKEN_ID: Pubkey = spl_token::ID.to_bytes(); +const SPL_TOKEN_2022_ID: Pubkey = spl_token_2022::ID.to_bytes(); +const CTOKEN_PROGRAM_ID: Pubkey = light_ctoken_interface::CTOKEN_PROGRAM_ID; +const SYSTEM_PROGRAM_ID: Pubkey = [0u8; 32]; +const RANDOM_PROGRAM_ID: Pubkey = [42u8; 32]; + +const ACCOUNT_TYPE_UNINITIALIZED: u8 = 0; +const ACCOUNT_TYPE_MINT: u8 = 1; +const ACCOUNT_TYPE_ACCOUNT: u8 = 2; +const ACCOUNT_TYPE_UNKNOWN: u8 = 3; + +/// Owner types for testing +#[derive(Debug, Clone, Copy)] +enum Owner { + SplToken, + Token2022, + CToken, + SystemProgram, + RandomProgram, +} + +impl Owner { + fn pubkey(&self) -> &Pubkey { + match self { + Owner::SplToken => &SPL_TOKEN_ID, + Owner::Token2022 => &SPL_TOKEN_2022_ID, + Owner::CToken => &CTOKEN_PROGRAM_ID, + Owner::SystemProgram => &SYSTEM_PROGRAM_ID, + Owner::RandomProgram => &RANDOM_PROGRAM_ID, + } + } +} + +/// Data configurations for testing +#[derive(Debug, Clone)] +enum MintData { + Empty, + TooSmall(usize), // < 82 bytes + ExactSplSize, // 82 bytes (valid for all) + BetweenSizes(usize), // 83-165 bytes + WithAccountType(u8), // 166+ bytes with specific AccountType +} + +impl MintData { + fn to_bytes(&self) -> Vec { + match self { + MintData::Empty => vec![], + MintData::TooSmall(size) => vec![0u8; *size], + MintData::ExactSplSize => vec![0u8; 82], + MintData::BetweenSizes(size) => vec![0u8; *size], + MintData::WithAccountType(account_type) => { + let mut data = vec![0u8; 170]; + data[165] = *account_type; + data + } + } + } +} + +/// Expected result for a test case +#[derive(Debug, Clone, Copy, PartialEq)] +enum Expected { + Valid, // Ok(true) + Invalid, // Ok(false) + IncorrectProgramId, // Err(IncorrectProgramId) +} + +/// Test case definition +struct TestCase { + owner: Owner, + data: MintData, + expected: Expected, + description: &'static str, +} + +fn run_test_case(tc: &TestCase) { + let data = tc.data.to_bytes(); + let result = is_valid_mint(tc.owner.pubkey(), &data); + + match tc.expected { + Expected::Valid => { + assert!( + result.as_ref().map(|v| *v).unwrap_or(false), + "FAILED: {} - expected Ok(true), got {:?}", + tc.description, + result + ); + } + Expected::Invalid => { + assert!( + result.as_ref().map(|v| !*v).unwrap_or(false), + "FAILED: {} - expected Ok(false), got {:?}", + tc.description, + result + ); + } + Expected::IncorrectProgramId => { + assert!( + result.as_ref().err() == Some(&ProgramError::IncorrectProgramId), + "FAILED: {} - expected Err(IncorrectProgramId), got {:?}", + tc.description, + result + ); + } + } +} + +/// Systematically test all owner x data combinations +#[test] +fn test_is_valid_mint_all_combinations() { + let test_cases = vec![ + // ========================================================================= + // INVALID OWNERS - should always return Err(IncorrectProgramId) + // ========================================================================= + TestCase { + owner: Owner::SystemProgram, + data: MintData::ExactSplSize, + expected: Expected::IncorrectProgramId, + description: "System program owner with 82 bytes", + }, + TestCase { + owner: Owner::RandomProgram, + data: MintData::ExactSplSize, + expected: Expected::IncorrectProgramId, + description: "Random program owner with 82 bytes", + }, + TestCase { + owner: Owner::SystemProgram, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::IncorrectProgramId, + description: "System program owner with AccountType=Mint", + }, + // ========================================================================= + // SPL TOKEN - only accepts exactly 82 bytes + // ========================================================================= + TestCase { + owner: Owner::SplToken, + data: MintData::Empty, + expected: Expected::Invalid, + description: "SPL: empty data", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::TooSmall(40), + expected: Expected::Invalid, + description: "SPL: 40 bytes (< 82)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::TooSmall(81), + expected: Expected::Invalid, + description: "SPL: 81 bytes (off by one)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::ExactSplSize, + expected: Expected::Valid, + description: "SPL: exactly 82 bytes (valid mint)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::BetweenSizes(83), + expected: Expected::Invalid, + description: "SPL: 83 bytes (off by one, too large)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::BetweenSizes(165), + expected: Expected::Invalid, + description: "SPL: 165 bytes (token account size)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::Invalid, + description: "SPL: 170 bytes with AccountType=Mint (SPL doesnt support extensions)", + }, + TestCase { + owner: Owner::SplToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_ACCOUNT), + expected: Expected::Invalid, + description: "SPL: 170 bytes with AccountType=Account", + }, + // ========================================================================= + // TOKEN-2022 - accepts 82 bytes OR 166+ with AccountType=Mint + // ========================================================================= + TestCase { + owner: Owner::Token2022, + data: MintData::Empty, + expected: Expected::Invalid, + description: "T22: empty data", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::TooSmall(40), + expected: Expected::Invalid, + description: "T22: 40 bytes (< 82)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::TooSmall(81), + expected: Expected::Invalid, + description: "T22: 81 bytes (off by one)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::ExactSplSize, + expected: Expected::Valid, + description: "T22: exactly 82 bytes (valid mint without extensions)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::BetweenSizes(83), + expected: Expected::Invalid, + description: "T22: 83 bytes (invalid - between sizes)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::BetweenSizes(165), + expected: Expected::Invalid, + description: "T22: 165 bytes (edge case - no AccountType marker)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNINITIALIZED), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=0 (uninitialized)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::Valid, + description: "T22: 170 bytes with AccountType=Mint (valid)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_ACCOUNT), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=Account (token account)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNKNOWN), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=3 (unknown)", + }, + TestCase { + owner: Owner::Token2022, + data: MintData::WithAccountType(255), + expected: Expected::Invalid, + description: "T22: 170 bytes with AccountType=255 (invalid)", + }, + // ========================================================================= + // CTOKEN - must always be >165 bytes with AccountType=Mint + // ========================================================================= + TestCase { + owner: Owner::CToken, + data: MintData::Empty, + expected: Expected::Invalid, + description: "CToken: empty data", + }, + TestCase { + owner: Owner::CToken, + data: MintData::TooSmall(40), + expected: Expected::Invalid, + description: "CToken: 40 bytes (< 82)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::TooSmall(81), + expected: Expected::Invalid, + description: "CToken: 81 bytes (off by one)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::ExactSplSize, + expected: Expected::Invalid, + description: "CToken: 82 bytes (invalid - CToken always has extensions)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::BetweenSizes(83), + expected: Expected::Invalid, + description: "CToken: 83 bytes (invalid - between sizes)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::BetweenSizes(165), + expected: Expected::Invalid, + description: "CToken: 165 bytes (edge case - no AccountType marker)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNINITIALIZED), + expected: Expected::Invalid, + description: "CToken: 170 bytes with AccountType=0 (uninitialized)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_MINT), + expected: Expected::Valid, + description: "CToken: 170 bytes with AccountType=Mint (valid)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_ACCOUNT), + expected: Expected::Invalid, + description: "CToken: 170 bytes with AccountType=Account (token account)", + }, + TestCase { + owner: Owner::CToken, + data: MintData::WithAccountType(ACCOUNT_TYPE_UNKNOWN), + expected: Expected::Invalid, + description: "CToken: 170 bytes with AccountType=3 (unknown)", + }, + ]; + + println!( + "\nRunning {} test cases for is_valid_mint:\n", + test_cases.len() + ); + + let mut passed = 0; + let mut failed = 0; + + for tc in &test_cases { + print!(" {:60} ... ", tc.description); + let data = tc.data.to_bytes(); + let result = is_valid_mint(tc.owner.pubkey(), &data); + + let success = match tc.expected { + Expected::Valid => result.as_ref().map(|v| *v).unwrap_or(false), + Expected::Invalid => result.as_ref().map(|v| !*v).unwrap_or(false), + Expected::IncorrectProgramId => { + result.as_ref().err() == Some(&ProgramError::IncorrectProgramId) + } + }; + + if success { + println!("ok"); + passed += 1; + } else { + println!("FAILED (got {:?})", result); + failed += 1; + } + } + + println!("\nResults: {} passed, {} failed\n", passed, failed); + + // Now run assertions to fail the test if any failed + for tc in &test_cases { + run_test_case(tc); + } +} diff --git a/programs/compressed-token/program/tests/multi_sum_check.rs b/programs/compressed-token/program/tests/multi_sum_check.rs index d33091d221..96d4ded630 100644 --- a/programs/compressed-token/program/tests/multi_sum_check.rs +++ b/programs/compressed-token/program/tests/multi_sum_check.rs @@ -6,7 +6,7 @@ use light_account_checks::{ account_info::test_account_info::pinocchio::get_account_info, packed_accounts::ProgramPackedAccounts, }; -use light_compressed_token::transfer2::sum_check::{ +use light_compressed_token::compressed_token::transfer2::sum_check::{ sum_check_multi_mint, validate_mint_uniqueness, }; use light_ctoken_interface::instructions::transfer2::{ diff --git a/programs/compressed-token/program/tests/queue_indices.rs b/programs/compressed-token/program/tests/queue_indices.rs index 886d65e6c8..13bfa8731d 100644 --- a/programs/compressed-token/program/tests/queue_indices.rs +++ b/programs/compressed-token/program/tests/queue_indices.rs @@ -1,6 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::AnchorSerialize; -use light_compressed_token::mint_action::queue_indices::QueueIndices; +use light_compressed_token::compressed_token::mint_action::queue_indices::QueueIndices; use light_ctoken_interface::instructions::mint_action::CpiContext; use light_zero_copy::traits::ZeroCopyAt; From c4dff3613008c3e8b7eacb998a35fb2885b5614f Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 6 Jan 2026 00:41:16 +0000 Subject: [PATCH 05/38] fix doc error codes --- .../docs/compressed_token/MINT_ACTION.md | 26 +++++++-------- .../docs/compressed_token/TRANSFER2.md | 32 +++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index e26737a365..3e6c045b87 100644 --- a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -214,19 +214,19 @@ Packed accounts (remaining accounts): - `ProgramError::InvalidInstructionData` (error code: 3) - Failed to deserialize instruction data or invalid action configuration - `ProgramError::InvalidAccountData` (error code: 4) - Account validation failures (wrong program ownership, invalid PDA derivation) - `ProgramError::InvalidArgument` (error code: 1) - Invalid authority or action parameters -- `ErrorCode::MintActionProofMissing` (error code: 6070) - ZK proof required but not provided -- `ErrorCode::InvalidAuthorityMint` (error code: 6076) - Signer doesn't match mint authority -- `ErrorCode::MintActionAmountTooLarge` (error code: 6101) - Arithmetic overflow in mint amount calculations -- `ErrorCode::MintAccountMismatch` (error code: 6102) - SPL mint account doesn't match expected cmint -- `ErrorCode::InvalidAddressTree` (error code: 6069) - Wrong address merkle tree for mint creation -- `ErrorCode::MintActionMissingSplMintSigner` (error code: 6058) - Missing mint signer for SPL mint creation -- `ErrorCode::MintActionMissingMintAccount` (error code: 6061) - Missing SPL mint account when required -- `ErrorCode::MintActionMissingTokenPoolAccount` (error code: 6062) - Missing token pool PDA when required -- `ErrorCode::MintActionMissingTokenProgram` (error code: 6063) - Missing token program when required -- `ErrorCode::MintActionInvalidExtensionIndex` (error code: 6079) - Extension index out of bounds -- `ErrorCode::MintActionInvalidExtensionType` (error code: 6081) - Extension is not TokenMetadata type -- `ErrorCode::MintActionMetadataKeyNotFound` (error code: 6082) - Metadata key not found for removal -- `ErrorCode::MintActionMissingExecutingAccounts` (error code: 6083) - Missing required execution accounts +- `ErrorCode::MintActionProofMissing` (error code: 6055) - ZK proof required but not provided +- `ErrorCode::InvalidAuthorityMint` (error code: 6018) - Signer doesn't match mint authority +- `ErrorCode::MintActionAmountTooLarge` (error code: 6069) - Arithmetic overflow in mint amount calculations +- `ErrorCode::MintAccountMismatch` (error code: 6051) - SPL mint account doesn't match expected cmint +- `ErrorCode::InvalidAddressTree` (error code: 6094) - Wrong address merkle tree for mint creation +- `ErrorCode::MintActionMissingSplMintSigner` (error code: 6045) - Missing mint signer for SPL mint creation +- `ErrorCode::MintActionMissingMintAccount` (error code: 6048) - Missing SPL mint account when required +- `ErrorCode::MintActionMissingTokenPoolAccount` (error code: 6049) - Missing token pool PDA when required +- `ErrorCode::MintActionMissingTokenProgram` (error code: 6050) - Missing token program when required +- `ErrorCode::MintActionInvalidExtensionIndex` (error code: 6059) - Extension index out of bounds +- `ErrorCode::MintActionInvalidExtensionType` (error code: 6062) - Extension is not TokenMetadata type +- `ErrorCode::MintActionMetadataKeyNotFound` (error code: 6063) - Metadata key not found for removal +- `ErrorCode::MintActionMissingExecutingAccounts` (error code: 6064) - Missing required execution accounts - `ErrorCode::CpiContextExpected` (error code: 6085) - CPI context required but not provided - `AccountError::InvalidSigner` (error code: 12015) - Required signer account is not signing - `AccountError::NotEnoughAccountKeys` (error code: 12020) - Missing required accounts diff --git a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md index 56a15f97d2..310de0dcd4 100644 --- a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -341,14 +341,14 @@ When compression processing occurs (in both Path A and Path B): - `CTokenError::InsufficientSupply` (error code: 18010) - Insufficient token supply for operation - `CTokenError::ArithmeticOverflow` (error code: 18003) - Arithmetic overflow in balance calculations - `ErrorCode::SumCheckFailed` (error code: 6005) - Input/output token amounts don't match -- `ErrorCode::InputsOutOfOrder` (error code: 6054) - Sum inputs mint indices not in ascending order -- `ErrorCode::TooManyMints` (error code: 6055) - Sum check, too many mints (max 5) -- `ErrorCode::DuplicateMint` (error code: 6056) - Duplicate mint index detected in inputs, outputs, or compressions (same mint referenced by multiple indices or same index used multiple times) +- `ErrorCode::InputsOutOfOrder` (error code: 6038) - Sum inputs mint indices not in ascending order +- `ErrorCode::TooManyMints` (error code: 6039) - Sum check, too many mints (max 5) +- `ErrorCode::DuplicateMint` (error code: 6102) - Duplicate mint index detected in inputs, outputs, or compressions (same mint referenced by multiple indices or same index used multiple times) - `ErrorCode::ComputeOutputSumFailed` (error code: 6002) - Output mint not in inputs or compressions -- `ErrorCode::TooManyCompressionTransfers` (error code: 6106) - Too many compression transfers. Maximum 40 transfers allowed per instruction +- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Too many compression transfers. Maximum 40 transfers allowed per instruction - `ErrorCode::NoInputsProvided` (error code: 6025) - No compressions provided in early exit path (no compressed accounts) -- `ErrorCode::CompressionsOnlyMissingFeePayer` (error code: 6026) - Missing fee payer for compressions-only operations -- `ErrorCode::CompressionsOnlyMissingCpiAuthority` (error code: 6027) - Missing CPI authority PDA for compressions-only operations +- `ErrorCode::CompressionsOnlyMissingFeePayer` (error code: 6096) - Missing fee payer for compressions-only operations +- `ErrorCode::CompressionsOnlyMissingCpiAuthority` (error code: 6097) - Missing CPI authority PDA for compressions-only operations - `ErrorCode::OwnerMismatch` (error code: 6075) - Authority doesn't match account owner or delegate - `ErrorCode::Transfer2CpiContextWriteInvalidAccess` (error code: 6082) - Invalid access to system accounts during CPI write - `ErrorCode::Transfer2CpiContextWriteWithSolPool` (error code: 6083) - SOL pool operations not supported with CPI context write @@ -361,16 +361,16 @@ When compression processing occurs (in both Path A and Path B): - `ErrorCode::CompressAndCloseBalanceMismatch` (error code: 6091) - Token account balance must match compressed output amount - `ErrorCode::CompressAndCloseDelegateNotAllowed` (error code: 6092) - Source token account has delegate OR compressed output has delegate (delegates not supported) - `ErrorCode::CompressAndCloseInvalidVersion` (error code: 6093) - Compressed token version must be 3 (ShaFlat) and must match compressible extension's account_version -- `ErrorCode::CompressAndCloseInvalidMint` (error code: 6108) - Compressed token mint does not match source token account mint -- `ErrorCode::CompressAndCloseMissingCompressedOnlyExtension` (error code: 6109) - Missing required CompressedOnly extension for restricted mint or frozen account -- `ErrorCode::CompressAndCloseDelegatedAmountMismatch` (error code: 6116) - Delegated amount mismatch between ctoken and CompressedOnly extension -- `ErrorCode::CompressAndCloseInvalidDelegate` (error code: 6118) - Delegate mismatch between ctoken and compressed token output -- `ErrorCode::CompressAndCloseWithheldFeeMismatch` (error code: 6120) - Withheld transfer fee mismatch -- `ErrorCode::CompressAndCloseFrozenMismatch` (error code: 6122) - Frozen state mismatch between ctoken and CompressedOnly extension -- `ErrorCode::CompressedOnlyRequiresCTokenDecompress` (error code: 6144) - CompressedOnly inputs must decompress to CToken account, not SPL token account -- `ErrorCode::TlvRequiresVersion3` (error code: 6123) - TLV extensions only supported with version 3 (ShaFlat) -- `ErrorCode::CompressAndCloseDuplicateOutput` (error code: 6420) - Cannot use the same compressed output account for multiple CompressAndClose operations (security protection against fund theft) -- `ErrorCode::CompressAndCloseOutputMissing` (error code: 6421) - Compressed token account output required but not provided +- `ErrorCode::CompressAndCloseInvalidMint` (error code: 6132) - Compressed token mint does not match source token account mint +- `ErrorCode::CompressAndCloseMissingCompressedOnlyExtension` (error code: 6133) - Missing required CompressedOnly extension for restricted mint or frozen account +- `ErrorCode::CompressAndCloseDelegatedAmountMismatch` (error code: 6135) - Delegated amount mismatch between ctoken and CompressedOnly extension +- `ErrorCode::CompressAndCloseInvalidDelegate` (error code: 6136) - Delegate mismatch between ctoken and compressed token output +- `ErrorCode::CompressAndCloseWithheldFeeMismatch` (error code: 6137) - Withheld transfer fee mismatch +- `ErrorCode::CompressAndCloseFrozenMismatch` (error code: 6138) - Frozen state mismatch between ctoken and CompressedOnly extension +- `ErrorCode::CompressedOnlyRequiresCTokenDecompress` (error code: 6149) - CompressedOnly inputs must decompress to CToken account, not SPL token account +- `ErrorCode::TlvRequiresVersion3` (error code: 6139) - TLV extensions only supported with version 3 (ShaFlat) +- `ErrorCode::CompressAndCloseDuplicateOutput` (error code: 6106) - Cannot use the same compressed output account for multiple CompressAndClose operations (security protection against fund theft) +- `ErrorCode::CompressAndCloseOutputMissing` (error code: 6107) - Compressed token account output required but not provided - `AccountError::InvalidSigner` (error code: 12015) - Required signer account is not signing - `AccountError::AccountNotMutable` (error code: 12008) - Required mutable account is not mutable - Additional errors from close_token_account for CompressAndClose operations From 9db54e815d957e4a96df5050030e460d303d6050 Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 6 Jan 2026 20:30:48 +0000 Subject: [PATCH 06/38] refactor: unify process_compression_top_up with CalculateTopUp trait - Add CalculateTopUp trait for generic top-up calculations - Implement trait for CompressionInfo, ZCompressionInfo, ZCompressionInfoMut - Unify process_compression_top_up to use shared implementation - Remove duplicate function from compress_or_decompress_ctokens.rs - Fix extension ordering in assert_create_token_account (insert at index 0) --- .../compressible/src/compression_info.rs | 40 ++++++++ .../utils/src/assert_create_token_account.rs | 4 +- .../ctoken/compress_or_decompress_ctokens.rs | 49 ++-------- .../transfer2/compression/ctoken/mod.rs | 4 +- .../program/src/ctoken/approve_revoke.rs | 20 ++-- .../program/src/shared/compressible_top_up.rs | 95 ++++++++++++------- 6 files changed, 115 insertions(+), 97 deletions(-) diff --git a/program-libs/compressible/src/compression_info.rs b/program-libs/compressible/src/compression_info.rs index 4bb20e196b..cd46ceff75 100644 --- a/program-libs/compressible/src/compression_info.rs +++ b/program-libs/compressible/src/compression_info.rs @@ -15,6 +15,17 @@ use crate::{ AnchorDeserialize, AnchorSerialize, }; +/// Trait for types that can calculate top-up lamports for compressible accounts. +pub trait CalculateTopUp { + fn calculate_top_up_lamports( + &self, + num_bytes: u64, + current_slot: u64, + current_lamports: u64, + rent_exemption_lamports: u64, + ) -> Result; +} + /// Compressible extension for ctoken accounts. #[derive( Debug, @@ -129,6 +140,35 @@ impl_is_compressible!(CompressionInfo); impl_is_compressible!(ZCompressionInfo<'_>); impl_is_compressible!(ZCompressionInfoMut<'_>); +// Implement CalculateTopUp trait for all compressible extension types +macro_rules! impl_calculate_top_up { + ($struct_name:ty) => { + impl CalculateTopUp for $struct_name { + #[inline(always)] + fn calculate_top_up_lamports( + &self, + num_bytes: u64, + current_slot: u64, + current_lamports: u64, + rent_exemption_lamports: u64, + ) -> Result { + // Delegate to the inherent method + Self::calculate_top_up_lamports( + self, + num_bytes, + current_slot, + current_lamports, + rent_exemption_lamports, + ) + } + } + }; +} + +impl_calculate_top_up!(CompressionInfo); +impl_calculate_top_up!(ZCompressionInfo<'_>); +impl_calculate_top_up!(ZCompressionInfoMut<'_>); + // Unified macro to implement get_last_funded_epoch for all extension types macro_rules! impl_get_last_paid_epoch { ($struct_name:ty) => { diff --git a/program-tests/utils/src/assert_create_token_account.rs b/program-tests/utils/src/assert_create_token_account.rs index ad31b35957..014ad00484 100644 --- a/program-tests/utils/src/assert_create_token_account.rs +++ b/program-tests/utils/src/assert_create_token_account.rs @@ -214,9 +214,9 @@ pub async fn assert_create_token_account_internal( }, }; - // Add Compressible extension to extensions list + // Add Compressible extension to extensions list (at beginning, matching program order) let mut all_extensions = final_extensions.unwrap_or_default(); - all_extensions.push(ExtensionStruct::Compressible(compressible_ext)); + all_extensions.insert(0, ExtensionStruct::Compressible(compressible_ext)); // Create expected compressible token account with embedded compression info let expected_token_account = CToken { diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index 51a6b44029..1c5f61f478 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -8,18 +8,16 @@ use light_ctoken_interface::{ }; use light_program_profiler::profile; use light_zero_copy::traits::ZeroCopyAtMut; -use pinocchio::{ - account_info::AccountInfo, - pubkey::pubkey_eq, - sysvars::{clock::Clock, rent::Rent, Sysvar}, -}; +use pinocchio::pubkey::pubkey_eq; use spl_pod::solana_msg::msg; use super::{ compress_and_close::process_compress_and_close, decompress::apply_decompress_extension_state, inputs::CTokenCompressionInputs, }; -use crate::shared::owner_validation::check_ctoken_owner; +use crate::shared::{ + compressible_top_up::process_compression_top_up, owner_validation::check_ctoken_owner, +}; /// Perform compression/decompression on a ctoken account. /// @@ -78,6 +76,7 @@ pub fn compress_or_decompress_ctokens( &mut current_slot, transfer_amount, lamports_budget, + &mut None, )?; } Ok(()) @@ -100,6 +99,7 @@ pub fn compress_or_decompress_ctokens( &mut current_slot, transfer_amount, lamports_budget, + &mut None, )?; } Ok(()) @@ -115,43 +115,6 @@ pub fn compress_or_decompress_ctokens( } } -/// Process compression top-up using embedded compression info. -/// All ctoken accounts now have compression info embedded directly in meta. -#[inline(always)] -pub fn process_compression_top_up( - compression: &light_compressible::compression_info::ZCompressionInfoMut<'_>, - token_account_info: &AccountInfo, - current_slot: &mut u64, - transfer_amount: &mut u64, - lamports_budget: &mut u64, -) -> Result<(), ProgramError> { - if *transfer_amount != 0 { - return Ok(()); - } - - if *current_slot == 0 { - *current_slot = Clock::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .slot; - } - let rent_exemption = Rent::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .minimum_balance(token_account_info.data_len()); - - *transfer_amount = compression - .calculate_top_up_lamports( - token_account_info.data_len() as u64, - *current_slot, - token_account_info.lamports(), - rent_exemption, - ) - .map_err(|_| CTokenError::InvalidAccountData)?; - - *lamports_budget = lamports_budget.saturating_sub(*transfer_amount); - - Ok(()) -} - /// Validate a CToken account for compression/decompression operations. /// /// Checks: diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs index b52ba4a2d6..f268e7791e 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/mod.rs @@ -14,9 +14,7 @@ mod decompress; mod inputs; pub use compress_and_close::close_for_compress_and_close; -pub use compress_or_decompress_ctokens::{ - compress_or_decompress_ctokens, process_compression_top_up, -}; +pub use compress_or_decompress_ctokens::compress_or_decompress_ctokens; pub use inputs::{CTokenCompressionInputs, CompressAndCloseInputs, DecompressCompressOnlyInputs}; /// Process compression/decompression for ctoken accounts. diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index cb54ec26b1..1907ec697a 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -6,12 +6,9 @@ use pinocchio_token_program::processor::{ shared::approve::process_approve as shared_process_approve, unpack_amount_and_decimals, }; -use crate::{ - compressed_token::transfer2::compression::ctoken::process_compression_top_up, - shared::{ - convert_program_error, owner_validation::check_token_program_owner, - transfer_lamports_via_cpi, - }, +use crate::shared::{ + compressible_top_up::process_compression_top_up, convert_program_error, + owner_validation::check_token_program_owner, transfer_lamports_via_cpi, }; /// Account indices for approve instruction @@ -124,21 +121,17 @@ fn process_compressible_top_up( // Only process top-up if account has Compressible extension let transfer_amount = if let Some(compressible) = ctoken.get_compressible_extension() { let mut transfer_amount = 0u64; - let mut lamports_budget = if max_top_up == 0 { - u64::MAX - } else { - (max_top_up as u64).saturating_add(1) - }; process_compression_top_up( &compressible.info, account, &mut 0, &mut transfer_amount, - &mut lamports_budget, + &mut 0, + &mut None, )?; - if transfer_amount > 0 && lamports_budget == 0 { + if max_top_up > 0 && (max_top_up as u64) < transfer_amount { return Err(CTokenError::MaxTopUpExceeded.into()); } transfer_amount @@ -248,6 +241,7 @@ pub fn process_ctoken_approve_checked( &mut 0, &mut transfer_amount, &mut lamports_budget, + &mut None, )?; if transfer_amount > 0 && lamports_budget == 0 { diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index 43af640e6e..1f4531aebd 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -43,6 +43,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( let mut current_slot = 0; let mut rent: Option = None; + // Initialize budget: +1 allows exact match (total == max_top_up) let mut lamports_budget = (max_top_up as u64).saturating_add(1); @@ -51,25 +52,14 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( let cmint_data = cmint.try_borrow_data().map_err(convert_program_error)?; let (mint, _) = CompressedMint::zero_copy_at_checked(&cmint_data) .map_err(|_| CTokenError::CMintDeserializationFailed)?; - // Access compression info directly from meta (all cmints now have compression embedded) - if current_slot == 0 { - current_slot = Clock::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .slot; - rent = Some(Rent::get().map_err(|_| CTokenError::SysvarAccessError)?); - } - let rent_exemption = rent.as_ref().unwrap().minimum_balance(cmint.data_len()); - transfers[0].amount = mint - .base - .compression - .calculate_top_up_lamports( - cmint.data_len() as u64, - current_slot, - cmint.lamports(), - rent_exemption, - ) - .map_err(|_| CTokenError::InvalidAccountData)?; - lamports_budget = lamports_budget.saturating_sub(transfers[0].amount); + process_compression_top_up( + &mint.base.compression, + cmint, + &mut current_slot, + &mut transfers[0].amount, + &mut lamports_budget, + &mut rent, + )?; } // Calculate CToken top-up (only if not 165 bytes - 165 means no extensions) @@ -80,23 +70,14 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( let compressible = token .get_compressible_extension() .ok_or::(CTokenError::MissingCompressibleExtension.into())?; - if current_slot == 0 { - current_slot = Clock::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .slot; - rent = Some(Rent::get().map_err(|_| CTokenError::SysvarAccessError)?); - } - let rent_exemption = rent.as_ref().unwrap().minimum_balance(ctoken.data_len()); - transfers[1].amount = compressible - .info - .calculate_top_up_lamports( - ctoken.data_len() as u64, - current_slot, - ctoken.lamports(), - rent_exemption, - ) - .map_err(|_| CTokenError::InvalidAccountData)?; - lamports_budget = lamports_budget.saturating_sub(transfers[1].amount); + process_compression_top_up( + &compressible.info, + ctoken, + &mut current_slot, + &mut transfers[1].amount, + &mut lamports_budget, + &mut rent, + )?; } // Exit early if no compressible accounts @@ -116,3 +97,45 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( multi_transfer_lamports(payer, &transfers).map_err(convert_program_error)?; Ok(()) } + +/// Process compression top-up using embedded compression info. +/// All ctoken accounts now have compression info embedded directly in meta. +#[inline(always)] +pub fn process_compression_top_up( + compression: &T, + account_info: &AccountInfo, + current_slot: &mut u64, + transfer_amount: &mut u64, + lamports_budget: &mut u64, + rent: &mut Option, +) -> Result<(), ProgramError> { + if *transfer_amount != 0 { + return Ok(()); + } + + if *current_slot == 0 { + *current_slot = Clock::get() + .map_err(|_| CTokenError::SysvarAccessError)? + .slot; + } + if rent.is_none() { + *rent = Some(Rent::get().map_err(|_| CTokenError::SysvarAccessError)?); + } + let rent_exemption = rent + .as_ref() + .unwrap() + .minimum_balance(account_info.data_len()); + + *transfer_amount = compression + .calculate_top_up_lamports( + account_info.data_len() as u64, + *current_slot, + account_info.lamports(), + rent_exemption, + ) + .map_err(|_| CTokenError::InvalidAccountData)?; + + *lamports_budget = lamports_budget.saturating_sub(*transfer_amount); + + Ok(()) +} From 54a44611fcebed81e58d5064a6e852d88b61df3c Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 6 Jan 2026 21:49:15 +0000 Subject: [PATCH 07/38] fix: pinocchio token error conversion --- program-libs/ctoken-interface/src/error.rs | 4 ++ .../tests/ctoken/approve_revoke.rs | 4 +- .../tests/ctoken/burn.rs | 22 +++++----- .../tests/ctoken/transfer.rs | 14 +++---- programs/compressed-token/anchor/src/lib.rs | 32 +++++++++++++++ .../compressed_token/mint_action/accounts.rs | 2 +- .../actions/compress_and_close_cmint.rs | 4 +- .../mint_action/mint_input.rs | 4 +- .../mint_action/mint_output.rs | 22 +++++++--- .../mint_action/zero_copy_config.rs | 4 +- .../src/compressible/withdraw_funding_pool.rs | 4 +- .../program/src/ctoken/approve_revoke.rs | 18 +++++---- .../program/src/ctoken/burn.rs | 13 +++--- .../program/src/ctoken/freeze_thaw.rs | 6 +-- .../program/src/ctoken/mint_to.rs | 13 +++--- .../program/src/ctoken/transfer/checked.rs | 8 ++-- .../program/src/ctoken/transfer/default.rs | 5 ++- .../program/src/shared/compressible_top_up.rs | 4 +- .../src/shared/convert_program_error.rs | 40 +++++++++++++++++++ .../program/src/shared/mint_to_token_pool.rs | 4 +- .../program/src/shared/mod.rs | 2 +- 21 files changed, 159 insertions(+), 70 deletions(-) diff --git a/program-libs/ctoken-interface/src/error.rs b/program-libs/ctoken-interface/src/error.rs index e2a19d683e..801bedaaa0 100644 --- a/program-libs/ctoken-interface/src/error.rs +++ b/program-libs/ctoken-interface/src/error.rs @@ -188,6 +188,9 @@ pub enum CTokenError { "Decompress has withheld_transfer_fee but destination lacks TransferFeeAccount extension" )] DecompressWithheldFeeWithoutExtension, + + #[error("Missing required payer account")] + MissingPayer, } impl From for u32 { @@ -253,6 +256,7 @@ impl From for u32 { CTokenError::MintMismatch => 18058, CTokenError::DecompressDelegatedAmountWithoutDelegate => 18059, CTokenError::DecompressWithheldFeeWithoutExtension => 18060, + CTokenError::MissingPayer => 18061, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs index c8021d4c5c..07c33537bf 100644 --- a/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs +++ b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs @@ -83,7 +83,7 @@ async fn test_approve_fails() { 100, None, "non_existent_account", - 6000, // Pinocchio token program error - account doesn't exist + 6153, // NotRentExempt (SPL Token code 0 -> ErrorCode::NotRentExempt) ) .await; } @@ -253,7 +253,7 @@ async fn test_revoke_fails() { &owner, None, "non_existent_account", - 6000, // Pinocchio token program error - account doesn't exist + 6153, // NotRentExempt (SPL Token code 0 -> ErrorCode::NotRentExempt) ) .await; } diff --git a/program-tests/compressed-token-test/tests/ctoken/burn.rs b/program-tests/compressed-token-test/tests/ctoken/burn.rs index d27e1fe0e6..847365b6ec 100644 --- a/program-tests/compressed-token-test/tests/ctoken/burn.rs +++ b/program-tests/compressed-token-test/tests/ctoken/burn.rs @@ -103,12 +103,12 @@ async fn test_burn_success_cases() { // Burn Failure Cases // ============================================================================ -/// Error codes used in burn validation +/// Error codes used in burn validation (mapped to ErrorCode enum variants) mod error_codes { - /// Insufficient funds to complete the operation (SPL Token code 1) - pub const INSUFFICIENT_FUNDS: u32 = 1; - /// Authority doesn't match token account owner (SPL Token code 4) - pub const OWNER_MISMATCH: u32 = 4; + /// Insufficient funds to complete the operation (SplInsufficientFunds = 6154) + pub const INSUFFICIENT_FUNDS: u32 = 6154; + /// Authority doesn't match token account owner (OwnerMismatch = 6075) + pub const OWNER_MISMATCH: u32 = 6075; } #[tokio::test] @@ -142,8 +142,8 @@ async fn test_burn_fails() { ) .await; - // Non-existent CMint returns GenericError (code 0) - assert_rpc_error(result, 0, 0).unwrap(); + // Non-existent CMint returns NotRentExempt (SPL Token code 0 -> 6153) + assert_rpc_error(result, 0, 6153).unwrap(); println!("test_burn_fails: wrong mint passed"); } @@ -172,8 +172,8 @@ async fn test_burn_fails() { ) .await; - // Non-existent CToken account returns GenericError (code 0) - assert_rpc_error(result, 0, 0).unwrap(); + // Non-existent CToken account returns NotRentExempt (SPL Token code 0 -> 6153) + assert_rpc_error(result, 0, 6153).unwrap(); println!("test_burn_fails: non-existent account passed"); } @@ -399,8 +399,8 @@ async fn setup_burn_test() -> BurnTestContext { use light_ctoken_sdk::ctoken::BurnCTokenChecked; -/// MintDecimalsMismatch error code (SPL Token code 18) -const MINT_DECIMALS_MISMATCH: u32 = 18; +/// MintDecimalsMismatch error code (SplMintDecimalsMismatch = 6166) +const MINT_DECIMALS_MISMATCH: u32 = 6166; #[tokio::test] #[serial] diff --git a/program-tests/compressed-token-test/tests/ctoken/transfer.rs b/program-tests/compressed-token-test/tests/ctoken/transfer.rs index 998c825d93..bf58bb89ee 100644 --- a/program-tests/compressed-token-test/tests/ctoken/transfer.rs +++ b/program-tests/compressed-token-test/tests/ctoken/transfer.rs @@ -315,7 +315,7 @@ async fn test_ctoken_transfer_insufficient_balance() { let owner_keypair = context.owner_keypair.insecure_clone(); // Try to transfer more than the balance (1500 > 1000) - // Expected error: InsufficientFunds (error code 1) + // Expected error: SplInsufficientFunds (6154) transfer_and_assert_fails( &mut context, source, @@ -323,7 +323,7 @@ async fn test_ctoken_transfer_insufficient_balance() { 1500, &owner_keypair, "insufficient_balance_transfer", - 1, // InsufficientFunds + 6154, // SplInsufficientFunds ) .await; } @@ -393,7 +393,7 @@ async fn test_ctoken_transfer_wrong_authority() { let wrong_authority = Keypair::new(); // Try to transfer with wrong authority - // Expected error: OwnerMismatch (error code 4) + // Expected error: OwnerMismatch (6075) transfer_and_assert_fails( &mut context, source, @@ -401,7 +401,7 @@ async fn test_ctoken_transfer_wrong_authority() { 500, &wrong_authority, "wrong_authority_transfer", - 4, // OwnerMismatch + 6075, // OwnerMismatch ) .await; } @@ -425,7 +425,7 @@ async fn test_ctoken_transfer_mint_mismatch() { let owner_keypair = context.owner_keypair.insecure_clone(); // Try to transfer between accounts with different mints - // The SPL Token program returns error code 3 (MintMismatch) + // Expected error: SplMintMismatch (6155) transfer_and_assert_fails( &mut context, source, @@ -433,7 +433,7 @@ async fn test_ctoken_transfer_mint_mismatch() { 500, &owner_keypair, "mint_mismatch_transfer", - 3, // MintMismatch + 6155, // SplMintMismatch ) .await; } @@ -911,7 +911,7 @@ async fn test_ctoken_transfer_checked_insufficient_balance() { 9, &owner_keypair, "insufficient_balance_transfer_checked", - 1, // InsufficientFunds + 6154, // SplInsufficientFunds ) .await; } diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index 80dd79fca5..33346ab26b 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -548,6 +548,38 @@ pub enum ErrorCode { CompressionOnlyNotAllowed, // 6151 #[msg("Associated token accounts must have compression_only set")] AtaRequiresCompressionOnly, // 6152 + // ========================================================================= + // SPL Token compatible errors (mapped from pinocchio token processor) + // These mirror SPL Token error codes for consistent error reporting + // ========================================================================= + #[msg("Lamport balance below rent-exempt threshold")] + NotRentExempt, // 6153 (SPL Token code 0) + #[msg("Insufficient funds for the operation")] + InsufficientFunds, // 6154 (SPL Token code 1) + #[msg("Account not associated with this Mint")] + MintMismatch, // 6155 (SPL Token code 3) + #[msg("This token's supply is fixed and new tokens cannot be minted")] + FixedSupply, // 6156 (SPL Token code 5) + #[msg("Account already in use")] + AlreadyInUse, // 6157 (SPL Token code 6) + #[msg("Invalid number of provided signers")] + InvalidNumberOfProvidedSigners, // 6158 (SPL Token code 7) + #[msg("Invalid number of required signers")] + InvalidNumberOfRequiredSigners, // 6159 (SPL Token code 8) + #[msg("State is uninitialized")] + UninitializedState, // 6160 (SPL Token code 9) + #[msg("Instruction does not support native tokens")] + NativeNotSupported, // 6161 (SPL Token code 10) + #[msg("Invalid instruction")] + InvalidInstruction, // 6162 (SPL Token code 12) + #[msg("State is invalid for requested operation")] + InvalidState, // 6163 (SPL Token code 13) + #[msg("Operation overflowed")] + Overflow, // 6164 (SPL Token code 14) + #[msg("Account does not support specified authority type")] + AuthorityTypeNotSupported, // 6165 (SPL Token code 15) + #[msg("Mint decimals mismatch between the client and mint")] + MintDecimalsMismatch, // 6166 (SPL Token code 18) } /// Anchor error code offset - error codes start at 6000 diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index 36ae0594e6..5e988aea56 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -364,7 +364,7 @@ impl AccountsConfig { /// This is the case when the mint is decompressed (or being decompressed) and not being closed. /// When true, compressed account uses zero sentinel values (discriminator=[0;8], data_hash=[0;32]). #[inline(always)] - pub fn cmint_is_source_of_truth(&self) -> bool { + pub fn cmint_is_decompressed(&self) -> bool { (self.has_decompress_mint_action || self.cmint_decompressed) && !self.has_compress_and_close_cmint_action } diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs index 9ea9f77194..46f6249db2 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs @@ -107,9 +107,7 @@ pub fn process_compress_and_close_cmint_action( unsafe { cmint.assign(&[0u8; 32]); } - cmint - .resize(0) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32 + 6000))?; + cmint.resize(0).map_err(convert_program_error)?; } // 8. Set cmint_decompressed = false compressed_mint.metadata.cmint_decompressed = false; diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs index a48de9e63b..80af25aac1 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs @@ -28,8 +28,8 @@ pub fn create_input_compressed_mint_account( accounts_config: &AccountsConfig, ) -> Result<(), ProgramError> { // When CMint was source of truth (input state BEFORE actions), use zero sentinel values - // Use cmint_decompressed directly, not cmint_is_source_of_truth(), because: - // - cmint_is_source_of_truth() tells us the OUTPUT state (after actions) + // Use cmint_decompressed directly, not cmint_is_decompressed(), because: + // - cmint_is_decompressed() tells us the OUTPUT state (after actions) // - cmint_decompressed tells us the INPUT state (before actions) // For CompressAndCloseCMint: input has zero values (was decompressed), output has real data let (discriminator, input_data_hash) = if accounts_config.cmint_decompressed { diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index a1e227799b..1c417186e5 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -45,11 +45,13 @@ pub fn process_output_compressed_account<'a>( &mut compressed_mint, )?; // When decompressed (CMint is source of truth), use zero values - let cmint_is_source_of_truth = accounts_config.cmint_is_source_of_truth(); + // TODO: check whether I can just mutate is_decompressed instead of using has_compress_and_close_cmint_action + // TODO: double check that we cannot close and create in the same instruction + let cmint_is_decompressed = accounts_config.cmint_is_decompressed(); // Serialize state into CMint solana account // SKIP if CompressAndCloseCMint action is present (CMint is being closed) // SKIP if DecompressMint action is present (CMint is being closed) - if cmint_is_source_of_truth { + if cmint_is_decompressed { let cmint_account = validated_accounts .get_cmint() .ok_or(ErrorCode::CMintNotFound)?; @@ -138,9 +140,17 @@ pub fn process_output_compressed_account<'a>( .as_mut() .ok_or(ErrorCode::MintActionOutputSerializationFailed)?; - let (discriminator, data_hash) = if cmint_is_source_of_truth { - // Zero sentinel values indicate "data lives in CMint" - // Data buffer is empty (data_len=0), no serialization needed + let (discriminator, data_hash) = if cmint_is_decompressed { + if !compressed_account_data.data.is_empty() { + msg!( + "Data allocation for output mint account is wrong: {} (expected) != {} ", + 0, + compressed_account_data.data.len() + ); + return Err(ProgramError::InvalidAccountData); + } + // Zeroed discriminator and data hash preserve the address + // of a closed compressed account without any data. ([0u8; 8], [0u8; 32]) } else { // Serialize compressed mint for compressed account @@ -149,7 +159,7 @@ pub fn process_output_compressed_account<'a>( .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; if data.len() != compressed_account_data.data.len() { msg!( - "Data allocation for output mint account is wrong: {} != {}", + "Data allocation for output mint account is wrong: {} (expected) != {}", data.len(), compressed_account_data.data.len() ); diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs index 5beba029ac..ecef2e7ebb 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs @@ -86,7 +86,7 @@ pub fn get_zero_copy_configs( return Err(ErrorCode::TooManyMintToRecipients.into()); } // CMint is source of truth when decompressed and not closing - let cmint_is_source_of_truth = accounts_config.cmint_is_source_of_truth(); + let cmint_is_decompressed = accounts_config.cmint_is_decompressed(); let input = CpiConfigInput { input_accounts: { @@ -101,7 +101,7 @@ pub fn get_zero_copy_configs( let mut outputs = ArrayVec::new(); // First output is always the mint account // When CMint is source of truth, use data_len=0 (zero discriminator/hash) - let mint_data_len = if cmint_is_source_of_truth { + let mint_data_len = if cmint_is_decompressed { 0 } else { mint_data_len(&output_mint_config) diff --git a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs index 30c47200a3..0a54c94eb9 100644 --- a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs +++ b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs @@ -8,7 +8,7 @@ use pinocchio::{ use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; -use crate::shared::parse_config_account; +use crate::shared::{convert_program_error, parse_config_account}; /// Accounts required for the withdraw funding pool instruction pub struct WithdrawFundingPoolAccounts<'a> { @@ -115,5 +115,5 @@ pub fn process_withdraw_funding_pool( transfer .invoke_signed(&[signer]) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32 + 6000)) + .map_err(convert_program_error) } diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index 1907ec697a..3fbd6201ce 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -7,8 +7,8 @@ use pinocchio_token_program::processor::{ }; use crate::shared::{ - compressible_top_up::process_compression_top_up, convert_program_error, - owner_validation::check_token_program_owner, transfer_lamports_via_cpi, + compressible_top_up::process_compression_top_up, convert_pinocchio_token_error, + convert_program_error, owner_validation::check_token_program_owner, transfer_lamports_via_cpi, }; /// Account indices for approve instruction @@ -39,7 +39,7 @@ pub fn process_ctoken_approve( let source = accounts .get(APPROVE_ACCOUNT_SOURCE) .ok_or(ProgramError::NotEnoughAccountKeys)?; - process_approve(accounts, &instruction_data[..8]).map_err(convert_program_error)?; + process_approve(accounts, &instruction_data[..8]).map_err(convert_pinocchio_token_error)?; // Hot path: 165-byte accounts have no extensions, just call pinocchio directly if source.data_len() == 165 { return Ok(()); @@ -77,7 +77,7 @@ pub fn process_ctoken_revoke( .get(REVOKE_ACCOUNT_SOURCE) .ok_or(ProgramError::NotEnoughAccountKeys)?; - process_revoke(accounts).map_err(convert_program_error)?; + process_revoke(accounts).map_err(convert_pinocchio_token_error)?; // Hot path: 165-byte accounts have no extensions if source.data_len() == 165 { @@ -86,7 +86,7 @@ pub fn process_ctoken_revoke( let payer = accounts .get(REVOKE_ACCOUNT_OWNER) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + .ok_or(ProgramError::Custom(u32::from(CTokenError::MissingPayer)))?; // Parse max_top_up based on instruction data length (0 = no limit) let max_top_up = match instruction_data.len() { @@ -195,7 +195,7 @@ pub fn process_ctoken_approve_checked( if source.data_len() == 165 { check_token_program_owner(mint)?; return shared_process_approve(accounts, amount, Some(decimals)) - .map_err(convert_program_error); + .map_err(convert_pinocchio_token_error); } // Parse max_top_up from bytes 9-10 if present (0 = no limit) @@ -276,11 +276,13 @@ pub fn process_ctoken_approve_checked( } // Create 3-account slice [source, delegate, owner] - skip mint let approve_accounts = [*source, *delegate, *owner]; - shared_process_approve(&approve_accounts, amount, None).map_err(convert_program_error) + shared_process_approve(&approve_accounts, amount, None) + .map_err(convert_pinocchio_token_error) } else { // No cached decimals - validate via mint account check_token_program_owner(mint)?; // Use full 4-account layout [source, mint, delegate, owner] - shared_process_approve(accounts, amount, Some(decimals)).map_err(convert_program_error) + shared_process_approve(accounts, amount, Some(decimals)) + .map_err(convert_pinocchio_token_error) } } diff --git a/programs/compressed-token/program/src/ctoken/burn.rs b/programs/compressed-token/program/src/ctoken/burn.rs index 0b6a25b113..8817f18ffb 100644 --- a/programs/compressed-token/program/src/ctoken/burn.rs +++ b/programs/compressed-token/program/src/ctoken/burn.rs @@ -3,7 +3,9 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::{burn::process_burn, burn_checked::process_burn_checked}; -use crate::shared::compressible_top_up::calculate_and_execute_compressible_top_ups; +use crate::shared::{ + compressible_top_up::calculate_and_execute_compressible_top_ups, convert_pinocchio_token_error, +}; /// Process ctoken burn instruction /// @@ -45,14 +47,13 @@ pub fn process_ctoken_burn( }; // Call pinocchio burn - handles balance/supply updates, authority check, frozen check - process_burn(accounts, &instruction_data[..8]) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; + process_burn(accounts, &instruction_data[..8]).map_err(convert_pinocchio_token_error)?; // Calculate and execute top-ups for both CMint and CToken // burn account order: [ctoken, cmint, authority] - reverse of mint_to let ctoken = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; let cmint = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; - let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) } @@ -98,13 +99,13 @@ pub fn process_ctoken_burn_checked( // Call pinocchio burn_checked - validates decimals against CMint, handles balance/supply updates process_burn_checked(accounts, &instruction_data[..9]) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; + .map_err(convert_pinocchio_token_error)?; // Calculate and execute top-ups for both CMint and CToken // burn account order: [ctoken, cmint, authority] - reverse of mint_to let ctoken = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; let cmint = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; - let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) } diff --git a/programs/compressed-token/program/src/ctoken/freeze_thaw.rs b/programs/compressed-token/program/src/ctoken/freeze_thaw.rs index e9fd15ee71..d3867bd0e8 100644 --- a/programs/compressed-token/program/src/ctoken/freeze_thaw.rs +++ b/programs/compressed-token/program/src/ctoken/freeze_thaw.rs @@ -4,7 +4,7 @@ use pinocchio_token_program::processor::{ freeze_account::process_freeze_account, thaw_account::process_thaw_account, }; -use crate::shared::owner_validation::check_token_program_owner; +use crate::shared::{convert_pinocchio_token_error, owner_validation::check_token_program_owner}; /// Process CToken freeze account instruction. /// Validates mint ownership before calling pinocchio-token-program. @@ -13,7 +13,7 @@ pub fn process_ctoken_freeze_account(accounts: &[AccountInfo]) -> Result<(), Pro // accounts[1] is the mint let mint_info = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; check_token_program_owner(mint_info)?; - process_freeze_account(accounts).map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + process_freeze_account(accounts).map_err(convert_pinocchio_token_error) } /// Process CToken thaw account instruction. @@ -23,5 +23,5 @@ pub fn process_ctoken_thaw_account(accounts: &[AccountInfo]) -> Result<(), Progr // accounts[1] is the mint let mint_info = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; check_token_program_owner(mint_info)?; - process_thaw_account(accounts).map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + process_thaw_account(accounts).map_err(convert_pinocchio_token_error) } diff --git a/programs/compressed-token/program/src/ctoken/mint_to.rs b/programs/compressed-token/program/src/ctoken/mint_to.rs index 215525987e..35b1a4aab4 100644 --- a/programs/compressed-token/program/src/ctoken/mint_to.rs +++ b/programs/compressed-token/program/src/ctoken/mint_to.rs @@ -5,7 +5,9 @@ use pinocchio_token_program::processor::{ mint_to::process_mint_to, mint_to_checked::process_mint_to_checked, }; -use crate::shared::compressible_top_up::calculate_and_execute_compressible_top_ups; +use crate::shared::{ + compressible_top_up::calculate_and_execute_compressible_top_ups, convert_pinocchio_token_error, +}; /// Process ctoken mint_to instruction /// @@ -47,14 +49,13 @@ pub fn process_ctoken_mint_to( }; // Call pinocchio mint_to - handles supply/balance updates, authority check, frozen check - process_mint_to(accounts, &instruction_data[..8]) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; + process_mint_to(accounts, &instruction_data[..8]).map_err(convert_pinocchio_token_error)?; // Calculate and execute top-ups for both CMint and CToken // mint_to account order: [cmint, ctoken, authority] let cmint = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; let ctoken = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; - let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) } @@ -100,13 +101,13 @@ pub fn process_ctoken_mint_to_checked( // Call pinocchio mint_to_checked - validates decimals against CMint, handles supply/balance updates process_mint_to_checked(accounts, &instruction_data[..9]) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; + .map_err(convert_pinocchio_token_error)?; // Calculate and execute top-ups for both CMint and CToken // mint_to account order: [cmint, ctoken, authority] let cmint = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; let ctoken = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; - let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) } diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index 1c850fbdb4..fa9578a304 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -7,7 +7,7 @@ use pinocchio_token_program::processor::{ }; use super::shared::{process_transfer_extensions_transfer_checked, TransferAccounts}; -use crate::shared::owner_validation::check_token_program_owner; +use crate::shared::{convert_pinocchio_token_error, owner_validation::check_token_program_owner}; /// Account indices for CToken transfer_checked instruction /// Note: Different from ctoken_transfer - mint is at index 1 const ACCOUNT_SOURCE: usize = 0; @@ -50,7 +50,7 @@ pub fn process_ctoken_transfer_checked( // Hot path: 165-byte accounts have no extensions, skip all extension processing if source.data_len() == 165 && destination.data_len() == 165 { return process_transfer_checked(accounts, &instruction_data[..9], false) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)); + .map_err(convert_pinocchio_token_error); } let mint = accounts @@ -100,10 +100,10 @@ pub fn process_ctoken_transfer_checked( None, signer_is_validated, ) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + .map_err(convert_pinocchio_token_error) } else { check_token_program_owner(mint)?; process_transfer(accounts, amount, Some(decimals), signer_is_validated) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + .map_err(convert_pinocchio_token_error) } } diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index 02cb3bb94e..20e489df53 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -4,6 +4,7 @@ use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::transfer::process_transfer; use super::shared::{process_transfer_extensions_transfer, TransferAccounts}; +use crate::shared::convert_pinocchio_token_error; /// Account indices for CToken transfer instruction const ACCOUNT_SOURCE: usize = 0; @@ -43,7 +44,7 @@ pub fn process_ctoken_transfer( .ok_or(ProgramError::NotEnoughAccountKeys)?; if source.data_len() == 165 && destination.data_len() == 165 { return process_transfer(accounts, &instruction_data[..8], false) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)); + .map_err(convert_pinocchio_token_error); } // Parse max_top_up based on instruction data length @@ -62,7 +63,7 @@ pub fn process_ctoken_transfer( // Only pass the first 8 bytes (amount) to the SPL transfer processor process_transfer(accounts, &instruction_data[..8], signer_is_validated) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32)) + .map_err(convert_pinocchio_token_error) } fn process_extensions( diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index 1f4531aebd..3a1f732732 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -27,7 +27,7 @@ use super::{ pub fn calculate_and_execute_compressible_top_ups<'a>( cmint: &'a AccountInfo, ctoken: &'a AccountInfo, - payer: &'a AccountInfo, + payer: Option<&'a AccountInfo>, max_top_up: u16, ) -> Result<(), ProgramError> { let mut transfers = [ @@ -93,7 +93,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( if max_top_up != 0 && lamports_budget == 0 { return Err(CTokenError::MaxTopUpExceeded.into()); } - + let payer = payer.ok_or(CTokenError::MissingPayer)?; multi_transfer_lamports(payer, &transfers).map_err(convert_program_error)?; Ok(()) } diff --git a/programs/compressed-token/program/src/shared/convert_program_error.rs b/programs/compressed-token/program/src/shared/convert_program_error.rs index e04888d0c8..764d07395f 100644 --- a/programs/compressed-token/program/src/shared/convert_program_error.rs +++ b/programs/compressed-token/program/src/shared/convert_program_error.rs @@ -1,5 +1,45 @@ +use anchor_compressed_token::ErrorCode; + +/// Convert generic pinocchio errors to anchor ProgramError with +6000 offset. +/// Use this for system program operations, data access, and non-token operations. pub fn convert_program_error( pinocchio_program_error: pinocchio::program_error::ProgramError, ) -> anchor_lang::prelude::ProgramError { anchor_lang::prelude::ProgramError::Custom(u64::from(pinocchio_program_error) as u32 + 6000) } + +/// Convert pinocchio token processor errors to our custom ErrorCode. +/// Maps SPL Token error codes (0-18) to our enum variants for consistent error reporting. +/// +/// IMPORTANT: Only use this for pinocchio_token_program processor calls. +/// For system program and other operations, use `convert_program_error` instead. +pub fn convert_pinocchio_token_error( + pinocchio_error: pinocchio::program_error::ProgramError, +) -> anchor_lang::prelude::ProgramError { + let code = u64::from(pinocchio_error) as u32; + + let error_code = match code { + 0 => ErrorCode::NotRentExempt, + 1 => ErrorCode::InsufficientFunds, + 2 => ErrorCode::InvalidMint, + 3 => ErrorCode::MintMismatch, + 4 => ErrorCode::OwnerMismatch, + 5 => ErrorCode::FixedSupply, + 6 => ErrorCode::AlreadyInUse, + 7 => ErrorCode::InvalidNumberOfProvidedSigners, + 8 => ErrorCode::InvalidNumberOfRequiredSigners, + 9 => ErrorCode::UninitializedState, + 10 => ErrorCode::NativeNotSupported, + 11 => ErrorCode::NonNativeHasBalance, + 12 => ErrorCode::InvalidInstruction, + 13 => ErrorCode::InvalidState, + 14 => ErrorCode::Overflow, + 15 => ErrorCode::AuthorityTypeNotSupported, + 16 => ErrorCode::MintHasNoFreezeAuthority, + 17 => ErrorCode::AccountFrozen, + 18 => ErrorCode::MintDecimalsMismatch, + // Pass through unknown/higher codes with standard +6900 offset + _ => return anchor_lang::prelude::ProgramError::Custom(code + 6900), + }; + error_code.into() +} diff --git a/programs/compressed-token/program/src/shared/mint_to_token_pool.rs b/programs/compressed-token/program/src/shared/mint_to_token_pool.rs index 82a7b666bf..42c2b80fd8 100644 --- a/programs/compressed-token/program/src/shared/mint_to_token_pool.rs +++ b/programs/compressed-token/program/src/shared/mint_to_token_pool.rs @@ -7,7 +7,7 @@ use pinocchio::{ program::invoke_signed, }; -use crate::LIGHT_CPI_SIGNER; +use crate::{shared::convert_program_error, LIGHT_CPI_SIGNER}; /// Mint tokens to the token pool using SPL token mint_to instruction. /// This function is shared between create_spl_mint and mint_to_compressed processors @@ -55,5 +55,5 @@ pub fn mint_to_token_pool( &[mint_account, token_pool_account, cpi_authority_pda], &[signer], ) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32 + 6000)) + .map_err(convert_program_error) } diff --git a/programs/compressed-token/program/src/shared/mod.rs b/programs/compressed-token/program/src/shared/mod.rs index 3f52465d75..ee52d7043f 100644 --- a/programs/compressed-token/program/src/shared/mod.rs +++ b/programs/compressed-token/program/src/shared/mod.rs @@ -14,7 +14,7 @@ pub mod transfer_lamports; pub mod validate_ata_derivation; pub use config_account::{next_config_account, parse_config_account}; -pub use convert_program_error::convert_program_error; +pub use convert_program_error::{convert_pinocchio_token_error, convert_program_error}; pub use create_pda_account::{create_pda_account, verify_pda}; pub use initialize_ctoken_account::create_compressible_account; pub use light_account_checks::AccountIterator; From c8890691b5d3ac23900ba6a81d6facc1d73e79ee Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 6 Jan 2026 22:09:31 +0000 Subject: [PATCH 08/38] refactor: simplify ctoken account access with direct indexing - Replace redundant .get().ok_or() with direct indexing after length validation - Add SAFETY comments documenting the length invariants - Make payer optional - only required when top-up transfer is needed - Remove redundant process_extensions function in transfer/default.rs - Use CTokenError::MissingPayer consistently when payer is required --- .../program/src/ctoken/approve_revoke.rs | 28 +++++-------- .../program/src/ctoken/burn.rs | 10 +++-- .../program/src/ctoken/mint_to.rs | 10 +++-- .../program/src/ctoken/transfer/checked.rs | 18 +++------ .../program/src/ctoken/transfer/default.rs | 40 +++++-------------- 5 files changed, 36 insertions(+), 70 deletions(-) diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index 3fbd6201ce..e13b56e55e 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -45,9 +45,7 @@ pub fn process_ctoken_approve( return Ok(()); } - let payer = accounts - .get(APPROVE_ACCOUNT_OWNER) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts.get(APPROVE_ACCOUNT_OWNER); // Parse max_top_up based on instruction data length (0 = no limit) let max_top_up = match instruction_data.len() { @@ -84,9 +82,7 @@ pub fn process_ctoken_revoke( return Ok(()); } - let payer = accounts - .get(REVOKE_ACCOUNT_OWNER) - .ok_or(ProgramError::Custom(u32::from(CTokenError::MissingPayer)))?; + let payer = accounts.get(REVOKE_ACCOUNT_OWNER); // Parse max_top_up based on instruction data length (0 = no limit) let max_top_up = match instruction_data.len() { @@ -109,7 +105,7 @@ pub fn process_ctoken_revoke( #[inline(always)] fn process_compressible_top_up( account: &AccountInfo, - payer: &AccountInfo, + payer: Option<&AccountInfo>, max_top_up: u16, ) -> Result<(), ProgramError> { // Borrow account data to get extensions @@ -143,6 +139,7 @@ fn process_compressible_top_up( drop(account_data); if transfer_amount > 0 { + let payer = payer.ok_or(CTokenError::MissingPayer)?; transfer_lamports_via_cpi(transfer_amount, payer, account) .map_err(convert_program_error)?; } @@ -183,12 +180,9 @@ pub fn process_ctoken_approve_checked( let (amount, decimals) = unpack_amount_and_decimals(instruction_data).map_err(|e| ProgramError::Custom(e as u32))?; - let source = accounts - .get(APPROVE_CHECKED_ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let mint = accounts - .get(APPROVE_CHECKED_ACCOUNT_MINT) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 4 validated at function entry + let source = &accounts[APPROVE_CHECKED_ACCOUNT_SOURCE]; + let mint = &accounts[APPROVE_CHECKED_ACCOUNT_MINT]; // Hot path: 165-byte accounts have no extensions (no cached decimals, no top-up) // Validate via mint and use full 4-account layout @@ -209,12 +203,8 @@ pub fn process_ctoken_approve_checked( _ => return Err(ProgramError::InvalidInstructionData), }; - let delegate = accounts - .get(APPROVE_CHECKED_ACCOUNT_DELEGATE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let owner = accounts - .get(APPROVE_CHECKED_ACCOUNT_OWNER) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + let delegate = &accounts[APPROVE_CHECKED_ACCOUNT_DELEGATE]; + let owner = &accounts[APPROVE_CHECKED_ACCOUNT_OWNER]; // Borrow source account to check for cached decimals and handle top-up let cached_decimals = { diff --git a/programs/compressed-token/program/src/ctoken/burn.rs b/programs/compressed-token/program/src/ctoken/burn.rs index 8817f18ffb..aedd2b8f15 100644 --- a/programs/compressed-token/program/src/ctoken/burn.rs +++ b/programs/compressed-token/program/src/ctoken/burn.rs @@ -51,8 +51,9 @@ pub fn process_ctoken_burn( // Calculate and execute top-ups for both CMint and CToken // burn account order: [ctoken, cmint, authority] - reverse of mint_to - let ctoken = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; - let cmint = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 3 validated at function entry + let ctoken = &accounts[0]; + let cmint = &accounts[1]; let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) @@ -103,8 +104,9 @@ pub fn process_ctoken_burn_checked( // Calculate and execute top-ups for both CMint and CToken // burn account order: [ctoken, cmint, authority] - reverse of mint_to - let ctoken = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; - let cmint = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 3 validated at function entry + let ctoken = &accounts[0]; + let cmint = &accounts[1]; let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) diff --git a/programs/compressed-token/program/src/ctoken/mint_to.rs b/programs/compressed-token/program/src/ctoken/mint_to.rs index 35b1a4aab4..66c9f2bee5 100644 --- a/programs/compressed-token/program/src/ctoken/mint_to.rs +++ b/programs/compressed-token/program/src/ctoken/mint_to.rs @@ -53,8 +53,9 @@ pub fn process_ctoken_mint_to( // Calculate and execute top-ups for both CMint and CToken // mint_to account order: [cmint, ctoken, authority] - let cmint = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; - let ctoken = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 3 validated at function entry + let cmint = &accounts[0]; + let ctoken = &accounts[1]; let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) @@ -105,8 +106,9 @@ pub fn process_ctoken_mint_to_checked( // Calculate and execute top-ups for both CMint and CToken // mint_to account order: [cmint, ctoken, authority] - let cmint = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; - let ctoken = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 3 validated at function entry + let cmint = &accounts[0]; + let ctoken = &accounts[1]; let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index fa9578a304..27efa78cd1 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -39,13 +39,9 @@ pub fn process_ctoken_transfer_checked( return Err(ProgramError::InvalidInstructionData); } - // Get account references - let source = accounts - .get(ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let destination = accounts - .get(ACCOUNT_DESTINATION) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 4 validated at function entry + let source = &accounts[ACCOUNT_SOURCE]; + let destination = &accounts[ACCOUNT_DESTINATION]; // Hot path: 165-byte accounts have no extensions, skip all extension processing if source.data_len() == 165 && destination.data_len() == 165 { @@ -53,12 +49,8 @@ pub fn process_ctoken_transfer_checked( .map_err(convert_pinocchio_token_error); } - let mint = accounts - .get(ACCOUNT_MINT) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let authority = accounts - .get(ACCOUNT_AUTHORITY) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + let mint = &accounts[ACCOUNT_MINT]; + let authority = &accounts[ACCOUNT_AUTHORITY]; // Parse max_top_up based on instruction data length // 0 means no limit diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index 20e489df53..5d63f3accf 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -36,12 +36,9 @@ pub fn process_ctoken_transfer( } // Hot path: 165-byte accounts have no extensions, skip all extension processing - let source = accounts - .get(ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let destination = accounts - .get(ACCOUNT_DESTINATION) - .ok_or(ProgramError::NotEnoughAccountKeys)?; + // SAFETY: accounts.len() >= 3 validated at function entry + let source = &accounts[ACCOUNT_SOURCE]; + let destination = &accounts[ACCOUNT_DESTINATION]; if source.data_len() == 165 && destination.data_len() == 165 { return process_transfer(accounts, &instruction_data[..8], false) .map_err(convert_pinocchio_token_error); @@ -59,36 +56,19 @@ pub fn process_ctoken_transfer( _ => return Err(ProgramError::InvalidInstructionData), }; - let signer_is_validated = process_extensions(accounts, max_top_up)?; + let authority = &accounts[ACCOUNT_AUTHORITY]; - // Only pass the first 8 bytes (amount) to the SPL transfer processor - process_transfer(accounts, &instruction_data[..8], signer_is_validated) - .map_err(convert_pinocchio_token_error) -} - -fn process_extensions( - accounts: &[pinocchio::account_info::AccountInfo], - max_top_up: u16, -) -> Result { - let source = accounts - .get(ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let destination = accounts - .get(ACCOUNT_DESTINATION) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - let authority = accounts - .get(ACCOUNT_AUTHORITY) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - - // Ignore decimals - only used for transfer_checked - let (signer_is_validated, _decimals) = process_transfer_extensions_transfer( + let (signer_is_validated, _) = process_transfer_extensions_transfer( TransferAccounts { source, destination, authority, - mint: None, + mint: None, // No mint in transfer instruction }, max_top_up, )?; - Ok(signer_is_validated) + + // Only pass the first 8 bytes (amount) to the SPL transfer processor + process_transfer(accounts, &instruction_data[..8], signer_is_validated) + .map_err(convert_pinocchio_token_error) } From bf219fb5b0090465aebc7473d86d2483b5bbfcc6 Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 6 Jan 2026 22:45:27 +0000 Subject: [PATCH 09/38] fix: error conversions --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../tests/mint/ctoken_mint_to.rs | 4 +- programs/compressed-token/program/CLAUDE.md | 49 +++++++++++++++++++ .../compressed_token/transfer2/processor.rs | 19 +++++-- .../program/src/ctoken/approve_revoke.rs | 6 +-- .../program/src/ctoken/transfer/checked.rs | 5 +- .../src/shared/convert_program_error.rs | 13 ++++- .../program/src/shared/mod.rs | 2 +- 9 files changed, 86 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb42bc283c..71fde9c988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5072,7 +5072,7 @@ dependencies = [ [[package]] name = "pinocchio-token-interface" version = "0.0.0" -source = "git+https://github.com/Lightprotocol/token?rev=1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73#1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73" +source = "git+https://github.com/Lightprotocol/token?rev=0c55d18#0c55d185aaede4e83039ebfeb7a2caa000253450" dependencies = [ "pinocchio", "pinocchio-pubkey", @@ -5081,7 +5081,7 @@ dependencies = [ [[package]] name = "pinocchio-token-program" version = "0.1.0" -source = "git+https://github.com/Lightprotocol/token?rev=1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73#1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73" +source = "git+https://github.com/Lightprotocol/token?rev=0c55d18#0c55d185aaede4e83039ebfeb7a2caa000253450" dependencies = [ "pinocchio", "pinocchio-log", diff --git a/Cargo.toml b/Cargo.toml index 26d1df64dc..06653ca36b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -232,7 +232,7 @@ groth16-solana = { version = "0.2.0" } bytemuck = { version = "1.19.0" } arrayvec = "0.7" tinyvec = "1.10.0" -pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="1bf7a9e525e753c3eb7bbf9971a26efbc23e5c73" } +pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="0c55d18" } # Math and crypto num-bigint = "0.4.6" tabled = "0.20" diff --git a/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs b/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs index e60c4bc481..e0b9882f2f 100644 --- a/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs +++ b/program-tests/compressed-token-test/tests/mint/ctoken_mint_to.rs @@ -223,8 +223,8 @@ async fn test_ctoken_mint_to_checked_wrong_decimals() { ) .await; - // Should fail with MintDecimalsMismatch (error code 18 in pinocchio) + // Should fail with MintDecimalsMismatch (error code 18 in pinocchio mapped to 6166) assert!(result.is_err(), "Mint with wrong decimals should fail"); - light_program_test::utils::assert::assert_rpc_error(result, 0, 18).unwrap(); + light_program_test::utils::assert::assert_rpc_error(result, 0, 6166).unwrap(); println!("test_ctoken_mint_to_checked_wrong_decimals: passed"); } diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index 34b21a916c..9502adc282 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -206,6 +206,55 @@ Custom error codes are defined in **`programs/compressed-token/anchor/src/lib.rs - Errors are returned as `ProgramError::Custom(error_code as u32)` on-chain - CToken-specific errors are also defined in **`program-libs/ctoken-interface/src/error.rs`** (`CTokenError` enum) +### Error Conversion Functions (`shared/convert_program_error.rs`) + +Two functions exist for converting pinocchio errors to anchor ProgramError: + +| Function | Use Case | Error Mapping | +|----------|----------|---------------| +| `convert_pinocchio_token_error` | SPL Token operations via pinocchio_token_program processors | Maps SPL Token error codes (0-18) to named ErrorCode variants | +| `convert_token_error` | Functions returning TokenError directly (e.g., unpack_amount_and_decimals) | Maps SPL Token error codes (0-18) to named ErrorCode variants | +| `convert_program_error` | System program, data access, lamport transfers | Adds +6000 offset to raw error code | + +**When to use each:** + +```rust +// SPL Token operations - use convert_pinocchio_token_error +process_transfer(accounts, data).map_err(convert_pinocchio_token_error)?; +process_burn(accounts, data).map_err(convert_pinocchio_token_error)?; +process_mint_to(accounts, data).map_err(convert_pinocchio_token_error)?; + +// System/internal operations - use convert_program_error +transfer_lamports_via_cpi(...).map_err(convert_program_error)?; +account.try_borrow_mut_data().map_err(convert_program_error)?; + +// ErrorCode variants - use ProgramError::from directly +sum_check_multi_mint(...).map_err(ProgramError::from)?; +validate_mint_uniqueness(...).map_err(ProgramError::from)?; +``` + +**SPL Token Error Code Mapping:** +| SPL Code | ErrorCode Variant | Description | +|----------|-------------------|-------------| +| 0 | NotRentExempt | Lamport balance below rent-exempt threshold | +| 1 | InsufficientFunds | Insufficient funds for the operation | +| 2 | InvalidMint | Invalid mint account | +| 3 | MintMismatch | Account not associated with this Mint | +| 4 | OwnerMismatch | Owner does not match | +| 5 | FixedSupply | Token supply is fixed | +| 6 | AlreadyInUse | Account already in use | +| 7-8 | InvalidNumberOf*Signers | Signer count mismatch | +| 9 | UninitializedState | State is uninitialized | +| 10 | NativeNotSupported | Native tokens not supported | +| 11 | NonNativeHasBalance | Non-native account has balance | +| 12 | InvalidInstruction | Invalid instruction | +| 13 | InvalidState | State is invalid | +| 14 | Overflow | Operation overflowed | +| 15 | AuthorityTypeNotSupported | Authority type not supported | +| 16 | MintHasNoFreezeAuthority | Mint cannot freeze | +| 17 | AccountFrozen | Account is frozen | +| 18 | MintDecimalsMismatch | Decimals mismatch | + ## SDKs (`sdk-libs/`) - **`ctoken-sdk/`** - SDK for programs to interact with compressed tokens (CPIs, instruction builders) - **`token-client/`** - Client SDK for Rust applications (test helpers, transaction builders) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 20fd0c0875..8e7597b029 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -141,9 +141,18 @@ pub fn validate_instruction_data( if !allowed { return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); } + // All out_token_data must be version 3 if tlv is present. + let allowed = inputs.out_token_data.iter().all(|c| c.version == 3); + if !allowed { + return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); + } // Output count must match compressions count (no extra outputs) - let compressions_len = inputs.compressions.as_ref().map(|c| c.len()).unwrap_or(0); + let compressions_len = inputs + .compressions + .as_ref() + .map(|c| c.len()) + .ok_or(CTokenError::OutTlvOutputCountMismatch)?; if inputs.out_token_data.len() != compressions_len { msg!("out_tlv requires out_token_data.len() == compressions.len()"); return Err(CTokenError::OutTlvOutputCountMismatch); @@ -186,11 +195,11 @@ fn process_no_system_program_cpi<'a>( let mint_map: ArrayMap = sum_check_multi_mint(&[], &[], Some(compressions.as_slice())) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; // Validate mint uniqueness validate_mint_uniqueness(&mint_map, &validated_accounts.packed_accounts) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; // This is the compression-only hot path (no compressed inputs/outputs). // Extension checks are skipped because balance must be restored immediately @@ -263,11 +272,11 @@ fn process_with_system_program_cpi<'a>( &inputs.out_token_data, inputs.compressions.as_deref(), ) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; // Validate mint uniqueness validate_mint_uniqueness(&mint_map, &validated_accounts.packed_accounts) - .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + .map_err(ProgramError::from)?; if let Some(system_accounts) = validated_accounts.system.as_ref() { // Process token compressions/decompressions/close_and_compress diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index e13b56e55e..af859dea89 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -8,7 +8,8 @@ use pinocchio_token_program::processor::{ use crate::shared::{ compressible_top_up::process_compression_top_up, convert_pinocchio_token_error, - convert_program_error, owner_validation::check_token_program_owner, transfer_lamports_via_cpi, + convert_program_error, convert_token_error, owner_validation::check_token_program_owner, + transfer_lamports_via_cpi, }; /// Account indices for approve instruction @@ -177,8 +178,7 @@ pub fn process_ctoken_approve_checked( } // Parse amount and decimals from instruction data - let (amount, decimals) = - unpack_amount_and_decimals(instruction_data).map_err(|e| ProgramError::Custom(e as u32))?; + let (amount, decimals) = unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; // SAFETY: accounts.len() >= 4 validated at function entry let source = &accounts[APPROVE_CHECKED_ACCOUNT_SOURCE]; diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index 27efa78cd1..20cf3863b3 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -7,7 +7,7 @@ use pinocchio_token_program::processor::{ }; use super::shared::{process_transfer_extensions_transfer_checked, TransferAccounts}; -use crate::shared::{convert_pinocchio_token_error, owner_validation::check_token_program_owner}; +use crate::shared::{convert_pinocchio_token_error, convert_token_error, owner_validation::check_token_program_owner}; /// Account indices for CToken transfer_checked instruction /// Note: Different from ctoken_transfer - mint is at index 1 const ACCOUNT_SOURCE: usize = 0; @@ -75,8 +75,7 @@ pub fn process_ctoken_transfer_checked( )?; // Pass the first 9 bytes (amount + decimals) to the SPL transfer_checked processor - let (amount, decimals) = - unpack_amount_and_decimals(instruction_data).map_err(|e| ProgramError::Custom(e as u32))?; + let (amount, decimals) = unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; if let Some(extension_decimals) = extension_decimals { if extension_decimals != decimals { diff --git a/programs/compressed-token/program/src/shared/convert_program_error.rs b/programs/compressed-token/program/src/shared/convert_program_error.rs index 764d07395f..b8c114567e 100644 --- a/programs/compressed-token/program/src/shared/convert_program_error.rs +++ b/programs/compressed-token/program/src/shared/convert_program_error.rs @@ -1,4 +1,5 @@ use anchor_compressed_token::ErrorCode; +use pinocchio_token_program::error::TokenError; /// Convert generic pinocchio errors to anchor ProgramError with +6000 offset. /// Use this for system program operations, data access, and non-token operations. @@ -8,6 +9,12 @@ pub fn convert_program_error( anchor_lang::prelude::ProgramError::Custom(u64::from(pinocchio_program_error) as u32 + 6000) } +/// Convert TokenError directly to anchor ProgramError. +/// Use for functions returning TokenError (e.g., unpack_amount_and_decimals). +pub fn convert_token_error(e: TokenError) -> anchor_lang::prelude::ProgramError { + convert_spl_token_error_code(e as u32) +} + /// Convert pinocchio token processor errors to our custom ErrorCode. /// Maps SPL Token error codes (0-18) to our enum variants for consistent error reporting. /// @@ -16,8 +23,12 @@ pub fn convert_program_error( pub fn convert_pinocchio_token_error( pinocchio_error: pinocchio::program_error::ProgramError, ) -> anchor_lang::prelude::ProgramError { - let code = u64::from(pinocchio_error) as u32; + convert_spl_token_error_code(u64::from(pinocchio_error) as u32) +} +/// Internal: Map SPL Token error code (0-18) to ErrorCode. +#[inline(never)] +fn convert_spl_token_error_code(code: u32) -> anchor_lang::prelude::ProgramError { let error_code = match code { 0 => ErrorCode::NotRentExempt, 1 => ErrorCode::InsufficientFunds, diff --git a/programs/compressed-token/program/src/shared/mod.rs b/programs/compressed-token/program/src/shared/mod.rs index ee52d7043f..c372db8f9f 100644 --- a/programs/compressed-token/program/src/shared/mod.rs +++ b/programs/compressed-token/program/src/shared/mod.rs @@ -14,7 +14,7 @@ pub mod transfer_lamports; pub mod validate_ata_derivation; pub use config_account::{next_config_account, parse_config_account}; -pub use convert_program_error::{convert_pinocchio_token_error, convert_program_error}; +pub use convert_program_error::{convert_pinocchio_token_error, convert_program_error, convert_token_error}; pub use create_pda_account::{create_pda_account, verify_pda}; pub use initialize_ctoken_account::create_compressible_account; pub use light_account_checks::AccountIterator; From c72b0a95850fd9093bc4b9d74fdd184c1fc6ffa5 Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 6 Jan 2026 22:49:08 +0000 Subject: [PATCH 10/38] fix stackframe issues --- .../program/src/ctoken/transfer/default.rs | 18 +++++++++++++----- .../src/shared/convert_program_error.rs | 1 - 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/programs/compressed-token/program/src/ctoken/transfer/default.rs b/programs/compressed-token/program/src/ctoken/transfer/default.rs index 5d63f3accf..09bb0a8954 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/default.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/default.rs @@ -56,6 +56,17 @@ pub fn process_ctoken_transfer( _ => return Err(ProgramError::InvalidInstructionData), }; + let signer_is_validated = process_extensions(accounts, max_top_up)?; + + // Only pass the first 8 bytes (amount) to the SPL transfer processor + process_transfer(accounts, &instruction_data[..8], signer_is_validated) + .map_err(convert_pinocchio_token_error) +} + +fn process_extensions(accounts: &[AccountInfo], max_top_up: u16) -> Result { + // SAFETY: accounts.len() >= 3 validated in caller + let source = &accounts[ACCOUNT_SOURCE]; + let destination = &accounts[ACCOUNT_DESTINATION]; let authority = &accounts[ACCOUNT_AUTHORITY]; let (signer_is_validated, _) = process_transfer_extensions_transfer( @@ -63,12 +74,9 @@ pub fn process_ctoken_transfer( source, destination, authority, - mint: None, // No mint in transfer instruction + mint: None, }, max_top_up, )?; - - // Only pass the first 8 bytes (amount) to the SPL transfer processor - process_transfer(accounts, &instruction_data[..8], signer_is_validated) - .map_err(convert_pinocchio_token_error) + Ok(signer_is_validated) } diff --git a/programs/compressed-token/program/src/shared/convert_program_error.rs b/programs/compressed-token/program/src/shared/convert_program_error.rs index b8c114567e..a440c81496 100644 --- a/programs/compressed-token/program/src/shared/convert_program_error.rs +++ b/programs/compressed-token/program/src/shared/convert_program_error.rs @@ -27,7 +27,6 @@ pub fn convert_pinocchio_token_error( } /// Internal: Map SPL Token error code (0-18) to ErrorCode. -#[inline(never)] fn convert_spl_token_error_code(code: u32) -> anchor_lang::prelude::ProgramError { let error_code = match code { 0 => ErrorCode::NotRentExempt, From 13965d546df45e838f1854b4d8337734314b8f44 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 00:09:46 +0000 Subject: [PATCH 11/38] refactor and test check extensions transfer2 --- .../transfer2/check_extensions.rs | 29 +- .../transfer2/compression/mod.rs | 5 +- .../program/src/shared/owner_validation.rs | 2 +- .../program/tests/check_extensions.rs | 708 ++++++++++++++++++ 4 files changed, 729 insertions(+), 15 deletions(-) create mode 100644 programs/compressed-token/program/tests/check_extensions.rs diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs index 2cffecf417..cf2db23fde 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs @@ -79,14 +79,15 @@ pub fn build_mint_extension_cache<'a>( packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, ) -> Result { let mut cache: MintExtensionCache = ArrayMap::new(); - let deny_restricted_extensions = !inputs.out_token_data.is_empty(); + let no_compressed_outputs = inputs.out_token_data.is_empty(); + let deny_restricted_extensions = !no_compressed_outputs; // Collect mints from input token data for input in inputs.in_token_data.iter() { let mint_index = input.mint; if cache.get_by_key(&mint_index).is_none() { let mint_account = packed_accounts.get_u8(mint_index, "mint cache: input")?; - let checks = if inputs.out_token_data.is_empty() { + let checks = if no_compressed_outputs { // No outputs - bypass state checks (full decompress or transfer-only) parse_mint_extensions(mint_account)? } else { @@ -103,14 +104,9 @@ pub fn build_mint_extension_cache<'a>( if cache.get_by_key(&mint_index).is_none() { let mint_account = packed_accounts.get_u8(mint_index, "mint cache: compression")?; - let no_compressed_outputs = inputs.out_token_data.is_empty(); - let is_full_decompress = compression.mode.is_decompress() && no_compressed_outputs; - let checks = if compression.mode.is_compress_and_close() - || is_full_decompress - || no_compressed_outputs - { + let checks = if compression.mode.is_compress_and_close() || no_compressed_outputs { // Bypass extension state checks (paused, non-zero fees, non-nil transfer hook) - // when exiting compressed state: CompressAndClose, Decompress, or CToken→SPL + // when CompressAndClose, full Decompress, or CToken→SPL (compress and full decompress) parse_mint_extensions(mint_account)? } else { check_mint_extensions(mint_account, deny_restricted_extensions)? @@ -119,7 +115,7 @@ pub fn build_mint_extension_cache<'a>( // CompressAndClose with restricted extensions requires CompressedOnly output. // Compress/Decompress don't need additional validation here: // - Compress: blocked by check_mint_extensions when outputs exist - // - Decompress: bypassed (restoring existing state) + // - Decompress: no check it restores existing state if checks.has_restricted_extensions && compression.mode.is_compress_and_close() { let output_idx = compression.get_compressed_token_account_index()?; let has_compressed_only = inputs @@ -144,5 +140,18 @@ pub fn build_mint_extension_cache<'a>( } } + for output in inputs.out_token_data.iter() { + // All mints of outputs that have non zero amount must have an input or compression. + // Thus we only check outputs with zero amounts here. + if output.amount.get() == 0 { + let mint_index = output.mint; + if cache.get_by_key(&mint_index).is_none() { + let mint_account = packed_accounts.get_u8(mint_index, "mint cache: output")?; + let checks = check_mint_extensions(mint_account, true)?; + cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; + } + } + } + Ok(cache) } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index e01db37136..60d237757f 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -101,8 +101,6 @@ pub fn process_token_compression<'a>( )?; } SPL_TOKEN_ID => { - // SPL Token (not Token-2022) never has restricted extensions. - // Delegation is disregarded for decompression to SPL token accounts. spl::process_spl_compressions( compression, &SPL_TOKEN_ID.to_pubkey_bytes(), @@ -122,8 +120,7 @@ pub fn process_token_compression<'a>( return Err(ErrorCode::CompressedOnlyRequiresCTokenDecompress.into()); } - // Check if mint has restricted extensions from the cache. - // Delegation is disregarded for decompression to SPL token accounts. + // Propagate whether mint is restricted to enable correct derivation of the spl interface pda. let is_restricted = mint_checks .map(|checks| checks.has_restricted_extensions) .unwrap_or(false); diff --git a/programs/compressed-token/program/src/shared/owner_validation.rs b/programs/compressed-token/program/src/shared/owner_validation.rs index e983743187..ea0e5a42e1 100644 --- a/programs/compressed-token/program/src/shared/owner_validation.rs +++ b/programs/compressed-token/program/src/shared/owner_validation.rs @@ -103,7 +103,7 @@ pub fn check_ctoken_owner( if let Some(checks) = mint_checks { if let Some(permanent_delegate) = &checks.permanent_delegate { if pubkey_eq(authority_key, permanent_delegate) { - return Ok(()); // Permanent delegate can compress any account of this mint + return Ok(()); // Permanent delegate can (de)compress any account of this mint } } } diff --git a/programs/compressed-token/program/tests/check_extensions.rs b/programs/compressed-token/program/tests/check_extensions.rs new file mode 100644 index 0000000000..d588950f76 --- /dev/null +++ b/programs/compressed-token/program/tests/check_extensions.rs @@ -0,0 +1,708 @@ +//! Specific unit tests for build_mint_extension_cache and check_mint_extensions. +//! +//! Tests are organized into categories: +//! - Category 1: Failure tests for MintHasRestrictedExtensions +//! - Category 2: Failure tests for CompressAndClose +//! - Category 3: Success tests for bypass scenarios +//! - Category 4: Success tests for non-restricted mints +//! - Category 5: Direct check_mint_extensions tests + +use anchor_compressed_token::ErrorCode; +use anchor_lang::prelude::ProgramError; +use anchor_lang::solana_program::pubkey::Pubkey as SolanaPubkey; +use light_account_checks::{ + account_info::test_account_info::pinocchio::get_account_info, + packed_accounts::ProgramPackedAccounts, +}; +use light_compressed_token::{ + compressed_token::transfer2::check_extensions::build_mint_extension_cache, + extensions::check_mint_extensions, +}; +use light_ctoken_interface::instructions::{ + extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData}, + transfer2::{ + Compression, CompressionMode, CompressedTokenInstructionDataTransfer2, + MultiInputTokenDataWithContext, MultiTokenTransferOutputData, + }, +}; +use light_zero_copy::traits::ZeroCopyAt; +use pinocchio::pubkey::Pubkey; +use spl_pod::{optional_keys::OptionalNonZeroPubkey, primitives::PodBool}; +use spl_token_2022::{ + extension::{ + metadata_pointer::MetadataPointer, pausable::PausableConfig, + permanent_delegate::PermanentDelegate, transfer_fee::TransferFeeConfig, + transfer_hook::TransferHook, BaseStateWithExtensionsMut, ExtensionType, + PodStateWithExtensionsMut, + }, + pod::PodMint, +}; + +const ANCHOR_ERROR_OFFSET: u32 = 6000; +const SPL_TOKEN_2022_ID: [u8; 32] = spl_token_2022::ID.to_bytes(); +const SPL_TOKEN_ID: [u8; 32] = spl_token::ID.to_bytes(); + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Configuration for creating mock T22 mint with extensions. +#[derive(Default, Clone)] +struct MintConfig { + pub has_pausable: bool, + pub is_paused: bool, + pub has_transfer_fee: bool, + pub has_non_zero_fee: bool, + pub has_transfer_hook: bool, + pub has_non_nil_hook: bool, + pub has_permanent_delegate: bool, + pub has_metadata_pointer: bool, +} + +/// Create mock T22 mint data with specified extensions. +fn create_mock_t22_mint(config: &MintConfig) -> Vec { + use spl_token_2022::pod::PodCOption; + + let mut extensions = vec![]; + if config.has_pausable { + extensions.push(ExtensionType::Pausable); + } + if config.has_transfer_fee { + extensions.push(ExtensionType::TransferFeeConfig); + } + if config.has_transfer_hook { + extensions.push(ExtensionType::TransferHook); + } + if config.has_permanent_delegate { + extensions.push(ExtensionType::PermanentDelegate); + } + if config.has_metadata_pointer { + extensions.push(ExtensionType::MetadataPointer); + } + + let space = ExtensionType::try_calculate_account_len::(&extensions).unwrap(); + let mut data = vec![0u8; space]; + + let mut mint_state = + PodStateWithExtensionsMut::::unpack_uninitialized(&mut data).unwrap(); + + // Initialize base mint + mint_state.base.mint_authority = PodCOption::some(SolanaPubkey::new_unique()); + mint_state.base.decimals = 9; + mint_state.base.is_initialized = true.into(); + mint_state.base.freeze_authority = PodCOption::none(); + mint_state.base.supply = 1_000_000u64.into(); + mint_state.init_account_type().unwrap(); + + // Initialize extensions + if config.has_pausable { + let ext = mint_state + .init_extension::(true) + .unwrap(); + ext.authority = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + ext.paused = PodBool::from(config.is_paused); + } + + if config.has_transfer_fee { + let ext = mint_state + .init_extension::(true) + .unwrap(); + ext.transfer_fee_config_authority = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + ext.withdraw_withheld_authority = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + if config.has_non_zero_fee { + ext.older_transfer_fee.transfer_fee_basis_points = 100u16.into(); + ext.older_transfer_fee.maximum_fee = 1000u64.into(); + ext.newer_transfer_fee.transfer_fee_basis_points = 100u16.into(); + ext.newer_transfer_fee.maximum_fee = 1000u64.into(); + } + } + + if config.has_transfer_hook { + let ext = mint_state.init_extension::(true).unwrap(); + if config.has_non_nil_hook { + ext.program_id = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + } + } + + if config.has_permanent_delegate { + let ext = mint_state + .init_extension::(true) + .unwrap(); + ext.delegate = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + } + + if config.has_metadata_pointer { + let ext = mint_state + .init_extension::(true) + .unwrap(); + ext.metadata_address = + OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + } + + data +} + +/// Create mock SPL Token (non-T22) mint data. +fn create_mock_spl_token_mint() -> Vec { + // SPL Token mint is 82 bytes + let mut data = vec![0u8; 82]; + // Set is_initialized = true (offset 45, 1 byte) + data[45] = 1; + // Set decimals (offset 44) + data[44] = 9; + data +} + +/// Test configuration for instruction data. +#[derive(Default)] +struct TestConfig { + pub has_inputs: bool, + pub has_outputs: bool, + pub has_compressions: bool, + pub compression_mode: Option, + pub has_compressed_only_in_output: bool, + pub output_amount: u64, +} + +/// Create serialized instruction data for testing. +fn create_test_inputs(config: &TestConfig) -> Vec { + let in_token_data = if config.has_inputs { + vec![MultiInputTokenDataWithContext { + mint: 0, + amount: 100, + ..Default::default() + }] + } else { + vec![] + }; + + let out_token_data = if config.has_outputs { + vec![MultiTokenTransferOutputData { + mint: 0, + amount: config.output_amount, + ..Default::default() + }] + } else { + vec![] + }; + + let compressions = if config.has_compressions { + Some(vec![Compression { + mode: config.compression_mode.unwrap_or(CompressionMode::Compress), + amount: 100, + mint: 0, + source_or_recipient: 1, + authority: 2, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 0, + }]) + } else { + None + }; + + let out_tlv = if config.has_outputs && config.has_compressed_only_in_output { + Some(vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, + bump: 0, + owner_index: 0, + }, + )]]) + } else if config.has_outputs { + Some(vec![vec![]]) // Empty TLV for each output + } else { + None + }; + + 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, + max_top_up: 0, + cpi_context: None, + compressions, + proof: None, + in_token_data, + out_token_data, + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv, + }; + + borsh::to_vec(&instruction_data).unwrap() +} + +/// Run build_mint_extension_cache with test data. +fn run_build_cache_test( + serialized_inputs: &[u8], + mint_data: &[u8], + owner: [u8; 32], +) -> Result<(), ProgramError> { + let (inputs, _) = + CompressedTokenInstructionDataTransfer2::zero_copy_at(serialized_inputs).unwrap(); + + let mint_account = get_account_info( + Pubkey::from(owner), + owner, + false, + false, + false, + mint_data.to_vec(), + ); + + let accounts = [mint_account]; + let packed_accounts = ProgramPackedAccounts { accounts: &accounts }; + + build_mint_extension_cache(&inputs, &packed_accounts).map(|_| ()) +} + +/// Helper to assert specific error code. +fn assert_error(result: Result<(), ProgramError>, expected: ErrorCode) { + let expected_code = ANCHOR_ERROR_OFFSET + expected as u32; + assert!( + matches!(result, Err(ProgramError::Custom(code)) if code == expected_code), + "Expected error {:?} (code {}), got {:?}", + expected, + expected_code, + result + ); +} + +// ============================================================================ +// Category 1: Failure Cases - MintHasRestrictedExtensions +// ============================================================================ + +#[test] +fn test_input_with_pausable_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_input_with_permanent_delegate_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_permanent_delegate: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_input_with_transfer_fee_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_fee: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_input_with_transfer_hook_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_hook: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_compress_with_pausable_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Compress), + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_decompress_with_pausable_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Decompress), + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_zero_amount_output_with_restricted_extension_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_outputs: true, + output_amount: 0, // Zero amount output + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::MintHasRestrictedExtensions); +} + +// ============================================================================ +// Category 2: Failure Cases - CompressAndClose +// ============================================================================ + +#[test] +fn test_compress_and_close_missing_compressed_only_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, // Restricted extension + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::CompressAndClose), + has_outputs: true, + has_compressed_only_in_output: false, // Missing CompressedOnly + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::CompressAndCloseMissingCompressedOnlyExtension); +} + +#[test] +fn test_compress_and_close_empty_tlv_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_permanent_delegate: true, // Different restricted extension + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::CompressAndClose), + has_outputs: true, + has_compressed_only_in_output: false, // Empty TLV + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert_error(result, ErrorCode::CompressAndCloseMissingCompressedOnlyExtension); +} + +// ============================================================================ +// Category 3: Success Cases - Bypass Scenarios +// ============================================================================ + +#[test] +fn test_input_with_restricted_no_outputs_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, // Restricted, but no outputs = bypass + is_paused: true, // Even paused is OK with bypass + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: false, // No outputs = bypass + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!(result.is_ok(), "Should succeed with bypass, got {:?}", result); +} + +#[test] +fn test_compress_and_close_with_compressed_only_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + is_paused: true, // Even paused is OK for CompressAndClose + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::CompressAndClose), + has_outputs: true, + has_compressed_only_in_output: true, // Has required extension + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!(result.is_ok(), "Should succeed with CompressedOnly, got {:?}", result); +} + +#[test] +fn test_decompress_no_outputs_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_fee: true, + has_non_zero_fee: true, // Would fail if checked + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Decompress), + has_outputs: false, // No outputs = bypass + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!(result.is_ok(), "Should succeed with bypass, got {:?}", result); +} + +#[test] +fn test_compress_no_outputs_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_hook: true, + has_non_nil_hook: true, // Would fail if checked + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_compressions: true, + compression_mode: Some(CompressionMode::Compress), + has_outputs: false, // No outputs = bypass + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!(result.is_ok(), "Should succeed with bypass, got {:?}", result); +} + +// ============================================================================ +// Category 4: Success Cases - Non-Restricted Mints +// ============================================================================ + +#[test] +fn test_spl_token_mint_succeeds() { + let mint_data = create_mock_spl_token_mint(); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + // SPL Token mint is owned by spl_token::ID, not spl_token_2022::ID + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_ID); + assert!(result.is_ok(), "SPL Token should succeed, got {:?}", result); +} + +#[test] +fn test_t22_mint_no_extensions_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig::default()); // No extensions + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!(result.is_ok(), "T22 without extensions should succeed, got {:?}", result); +} + +#[test] +fn test_t22_mint_with_metadata_only_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_metadata_pointer: true, // Not a restricted extension + ..Default::default() + }); + let inputs = create_test_inputs(&TestConfig { + has_inputs: true, + has_outputs: true, + output_amount: 100, + ..Default::default() + }); + + let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); + assert!(result.is_ok(), "MetadataPointer should succeed, got {:?}", result); +} + +// ============================================================================ +// Category 5: Direct check_mint_extensions Tests +// ============================================================================ + +#[test] +fn test_check_mint_extensions_paused_mint() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + is_paused: true, + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // Call check_mint_extensions directly with deny_restricted=false + // This bypasses the restricted check and reaches the paused check + let result = check_mint_extensions(&mint_account, false); + assert_error(result.map(|_| ()), ErrorCode::MintPaused); +} + +#[test] +fn test_check_mint_extensions_non_zero_fee() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_fee: true, + has_non_zero_fee: true, + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + let result = check_mint_extensions(&mint_account, false); + assert_error(result.map(|_| ()), ErrorCode::NonZeroTransferFeeNotSupported); +} + +#[test] +fn test_check_mint_extensions_non_nil_hook() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_transfer_hook: true, + has_non_nil_hook: true, + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + let result = check_mint_extensions(&mint_account, false); + assert_error(result.map(|_| ()), ErrorCode::TransferHookNotSupported); +} + +#[test] +fn test_check_mint_extensions_deny_restricted_fails() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, // Restricted extension + is_paused: false, // Not paused, but still restricted + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // deny_restricted=true should fail even if mint state is valid + let result = check_mint_extensions(&mint_account, true); + assert_error(result.map(|_| ()), ErrorCode::MintHasRestrictedExtensions); +} + +#[test] +fn test_check_mint_extensions_deny_restricted_non_restricted_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_metadata_pointer: true, // Not a restricted extension + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // deny_restricted=true should succeed with non-restricted mint + let result = check_mint_extensions(&mint_account, true); + assert!(result.is_ok(), "Non-restricted mint should succeed, got {:?}", result); +} + +#[test] +fn test_check_mint_extensions_valid_mint_succeeds() { + let mint_data = create_mock_t22_mint(&MintConfig { + has_pausable: true, + is_paused: false, // Not paused + has_transfer_fee: true, + has_non_zero_fee: false, // Zero fee + has_transfer_hook: true, + has_non_nil_hook: false, // Nil hook + ..Default::default() + }); + let mint_account = get_account_info( + Pubkey::from(SPL_TOKEN_2022_ID), + SPL_TOKEN_2022_ID, + false, + false, + false, + mint_data, + ); + + // deny_restricted=false with all valid states should succeed + let result = check_mint_extensions(&mint_account, false); + assert!(result.is_ok(), "Valid mint should succeed, got {:?}", result); +} From e3b7bdb508a75d11b00776bbadd2951ccb936384 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 00:29:40 +0000 Subject: [PATCH 12/38] refactor: remove CreateSplMint dead code and cleanup - Remove CreateSplMint enum variant and CreateSplMintAction struct (never activated) - Remove create_spl_mint.rs file from ctoken-interface - Add MAX_COMPRESSIONS constant (32) with meaningful error message - Remove dead no_output_compressed_accounts field from Transfer2Config - Update documentation (MINT_ACTION.md, CLAUDE.md, lib.rs) - Update JS layout to remove CreateSplMint - Refactor check_extensions tests for better coverage --- .../src/v3/layout/layout-mint-action.ts | 8 -- .../mint_action/create_spl_mint.rs | 9 -- .../mint_action/instruction_data.rs | 9 +- .../src/instructions/mint_action/mod.rs | 2 - .../tests/mint/functional.rs | 33 +------ programs/compressed-token/anchor/src/lib.rs | 2 +- programs/compressed-token/program/CLAUDE.md | 2 +- .../docs/compressed_token/MINT_ACTION.md | 33 +++---- .../compressed_token/mint_action/accounts.rs | 30 ++----- .../mint_action/actions/process_actions.rs | 11 --- .../compressed_token/mint_action/processor.rs | 9 +- .../transfer2/compression/ctoken/inputs.rs | 4 +- .../transfer2/compression/mod.rs | 15 ++-- .../src/compressed_token/transfer2/config.rs | 4 - .../transfer2/token_inputs.rs | 8 +- .../program/src/ctoken/approve_revoke.rs | 3 +- .../program/src/ctoken/transfer/checked.rs | 7 +- programs/compressed-token/program/src/lib.rs | 14 +-- .../program/src/shared/mint_to_token_pool.rs | 2 +- .../program/src/shared/mod.rs | 4 +- .../program/src/shared/token_input.rs | 1 - .../program/tests/check_extensions.rs | 86 +++++++++++++------ .../program/tests/mint_action.rs | 42 +++------ .../v2/mint_action/account_metas.rs | 4 +- .../token-client/src/actions/mint_action.rs | 4 +- 25 files changed, 137 insertions(+), 209 deletions(-) delete mode 100644 program-libs/ctoken-interface/src/instructions/mint_action/create_spl_mint.rs diff --git a/js/compressed-token/src/v3/layout/layout-mint-action.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts index 6e5ac89456..ed75d7ea6c 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -37,8 +37,6 @@ export const UpdateAuthorityLayout = struct([ option(publicKey(), 'newAuthority'), ]); -export const CreateSplMintActionLayout = struct([u8('mintBump')]); - export const MintToCTokenActionLayout = struct([ u8('accountIndex'), u64('amount'), @@ -74,7 +72,6 @@ export const ActionLayout = rustEnum([ MintToCompressedActionLayout.replicate('mintToCompressed'), UpdateAuthorityLayout.replicate('updateMintAuthority'), UpdateAuthorityLayout.replicate('updateFreezeAuthority'), - CreateSplMintActionLayout.replicate('createSplMint'), MintToCTokenActionLayout.replicate('mintToCToken'), UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), @@ -233,10 +230,6 @@ export interface UpdateAuthority { newAuthority: PublicKey | null; } -export interface CreateSplMintAction { - mintBump: number; -} - export interface MintToCTokenAction { accountIndex: number; amount: bigint; @@ -274,7 +267,6 @@ export type Action = | { mintToCompressed: MintToCompressedAction } | { updateMintAuthority: UpdateAuthority } | { updateFreezeAuthority: UpdateAuthority } - | { createSplMint: CreateSplMintAction } | { mintToCToken: MintToCTokenAction } | { updateMetadataField: UpdateMetadataFieldAction } | { updateMetadataAuthority: UpdateMetadataAuthorityAction } diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/create_spl_mint.rs b/program-libs/ctoken-interface/src/instructions/mint_action/create_spl_mint.rs deleted file mode 100644 index 8b141eaad6..0000000000 --- a/program-libs/ctoken-interface/src/instructions/mint_action/create_spl_mint.rs +++ /dev/null @@ -1,9 +0,0 @@ -use light_zero_copy::ZeroCopy; - -use crate::{AnchorDeserialize, AnchorSerialize}; - -#[repr(C)] -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, ZeroCopy)] -pub struct CreateSplMintAction { - pub mint_bump: u8, -} diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs index 9f4cab62f6..890b8c4fd4 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs @@ -3,8 +3,8 @@ use light_compressible::compression_info::CompressionInfo; use light_zero_copy::ZeroCopy; use super::{ - CompressAndCloseCMintAction, CpiContext, CreateSplMintAction, DecompressMintAction, - MintToCTokenAction, MintToCompressedAction, RemoveMetadataKeyAction, UpdateAuthority, + CompressAndCloseCMintAction, CpiContext, DecompressMintAction, MintToCTokenAction, + MintToCompressedAction, RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, }; use crate::{ @@ -25,11 +25,6 @@ pub enum Action { UpdateMintAuthority(UpdateAuthority), /// Update freeze authority of a compressed mint account. UpdateFreezeAuthority(UpdateAuthority), - /// Create an spl mint for a cmint. - /// - existing supply is minted to a token pool account. - /// - mint and freeze authority are a ctoken pda. - /// - is an spl-token-2022 mint account. - CreateSplMint(CreateSplMintAction), /// Mint ctokens from a cmint to a ctoken solana account /// (tokens are not compressed but not spl tokens). MintToCToken(MintToCTokenAction), diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs b/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs index d9e4de2a52..7ba2438bfd 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/mod.rs @@ -1,7 +1,6 @@ mod builder; mod compress_and_close_cmint; mod cpi_context; -mod create_spl_mint; mod decompress_mint; mod instruction_data; mod mint_to_compressed; @@ -11,7 +10,6 @@ mod update_mint; pub use compress_and_close_cmint::*; pub use cpi_context::*; -pub use create_spl_mint::*; pub use decompress_mint::*; pub use instruction_data::*; pub use mint_to_compressed::*; diff --git a/program-tests/compressed-token-test/tests/mint/functional.rs b/program-tests/compressed-token-test/tests/mint/functional.rs index 5c45f9cb65..37211d0bdd 100644 --- a/program-tests/compressed-token-test/tests/mint/functional.rs +++ b/program-tests/compressed-token-test/tests/mint/functional.rs @@ -156,37 +156,8 @@ async fn test_create_compressed_mint() { ) .await; } - // // 3. Create SPL mint from compressed mint - // // Get compressed mint data before creating SPL mint - // { - // let pre_compressed_mint_account = rpc - // .indexer() - // .unwrap() - // .get_compressed_account(compressed_mint_address, None) - // .await - // .unwrap() - // .value.unwrap(); - // let pre_compressed_mint: CompressedMint = BorshDeserialize::deserialize( - // &mut pre_compressed_mint_account.data.unwrap().data.as_slice(), - // ) - // .unwrap(); - - // // Use our create_spl_mint action helper (automatically handles proofs, PDAs, and transaction) - // create_spl_mint( - // &mut rpc, - // compressed_mint_address, - // &mint_seed, - // &mint_authority_keypair, - // &payer, - // ) - // .await - // .unwrap(); - - // // Verify SPL mint was created using our assertion helper - // assert_spl_mint(&mut rpc, mint_seed.pubkey(), &pre_compressed_mint).await; - // } - // 4. Transfer compressed tokens to new recipient + // 3. Transfer compressed tokens to new recipient // Get the compressed token account for decompression let compressed_token_accounts = rpc .indexer() @@ -1256,7 +1227,7 @@ async fn test_mint_actions() { metadata: CompressedMintMetadata { version: 3, // With metadata mint: spl_mint_pda.into(), - cmint_decompressed: false, // Should be true after CreateSplMint action + cmint_decompressed: false, // Becomes true after DecompressMint action }, reserved: [0u8; 49], account_type: ACCOUNT_TYPE_MINT, diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index 33346ab26b..d74e90eb77 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -425,7 +425,7 @@ pub enum ErrorCode { CompressAndCloseInvalidVersion, // 6093 #[msg("InvalidAddressTree")] InvalidAddressTree, // 6094 - #[msg("Too many compression transfers. Maximum 40 transfers allowed per instruction")] + #[msg("Too many compression transfers. Maximum 32 transfers allowed per instruction")] TooManyCompressionTransfers, // 6095 #[msg("Missing fee payer for compressions-only operation")] CompressionsOnlyMissingFeePayer, // 6096 diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index 9502adc282..99290c34c5 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -73,7 +73,7 @@ Every instruction description must include the sections: 6. **MintAction** - [`docs/instructions/MINT_ACTION.md`](docs/instructions/MINT_ACTION.md) - Batch instruction for compressed mint management and mint operations (discriminator: 103, enum: `InstructionType::MintAction`) - - Supports 9 action types: CreateCompressedMint, MintTo, UpdateMintAuthority, UpdateFreezeAuthority, CreateSplMint, MintToCToken, UpdateMetadataField, UpdateMetadataAuthority, RemoveMetadataKey + - Supports 10 action types: CreateCompressedMint, MintTo, UpdateMintAuthority, UpdateFreezeAuthority, MintToCToken, UpdateMetadataField, UpdateMetadataAuthority, RemoveMetadataKey, DecompressMint, CompressAndCloseCMint - Handles both compressed and decompressed token minting 7. **CTokenTransfer** - [`docs/instructions/CTOKEN_TRANSFER.md`](docs/instructions/CTOKEN_TRANSFER.md) diff --git a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index 3e6c045b87..eb44dcae7c 100644 --- a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -7,7 +7,7 @@ **description:** Batch instruction for managing compressed mint accounts (cmints) and performing mint operations. A compressed mint account stores the mint's supply, decimals, authorities (mint/freeze), and optional TokenMetadata extension in compressed state. TokenMetadata is the only extension supported for compressed mints and provides fields for name, symbol, uri, update_authority, and additional key-value metadata. -This instruction supports 11 total actions - one creation action (controlled by `create_mint` flag) and 10 enum-based actions: +This instruction supports 10 total actions - one creation action (controlled by `create_mint` flag) and 9 enum-based actions: **Compressed mint creation (executed first when `create_mint` is Some):** 1. **Create Compressed Mint** - Create a new compressed mint account with initial authorities and optional TokenMetadata extension @@ -15,20 +15,19 @@ This instruction supports 11 total actions - one creation action (controlled by **Core mint operations (Action enum variants):** 2. `MintToCompressed` - Mint new compressed tokens to one or more compressed token accounts 3. `MintToCToken` - Mint new tokens to decompressed ctoken accounts (not SPL tokens) -4. `CreateSplMint` - Create an SPL Token 2022 mint for an existing compressed mint, enabling SPL interoperability **Authority updates (Action enum variants):** -5. `UpdateMintAuthority` - Update or remove the mint authority -6. `UpdateFreezeAuthority` - Update or remove the freeze authority +4. `UpdateMintAuthority` - Update or remove the mint authority +5. `UpdateFreezeAuthority` - Update or remove the freeze authority **TokenMetadata extension operations (Action enum variants):** -7. `UpdateMetadataField` - Update name, symbol, uri, or additional_metadata fields in the TokenMetadata extension -8. `UpdateMetadataAuthority` - Update the metadata update authority in the TokenMetadata extension -9. `RemoveMetadataKey` - Remove a key-value pair from additional_metadata in the TokenMetadata extension +6. `UpdateMetadataField` - Update name, symbol, uri, or additional_metadata fields in the TokenMetadata extension +7. `UpdateMetadataAuthority` - Update the metadata update authority in the TokenMetadata extension +8. `RemoveMetadataKey` - Remove a key-value pair from additional_metadata in the TokenMetadata extension **Decompress/Compress operations (Action enum variants):** -10. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. -11. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). +9. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. +10. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). Key concepts integrated: - **Compressed mint (cmint)**: Mint state stored in compressed account with deterministic address derived from associated SPL mint pubkey @@ -54,16 +53,15 @@ Key concepts integrated: - `mint`: Option - Full mint state including supply, decimals, metadata, authorities, and extensions (None when reading from decompressed CMint) 2. Action types (path: program-libs/ctoken-interface/src/instructions/mint_action/): - - `MintToCompressed(MintToCompressedAction)` - Mint tokens to compressed accounts (mint_to.rs) + - `MintToCompressed(MintToCompressedAction)` - Mint tokens to compressed accounts (mint_to_compressed.rs) - `UpdateMintAuthority(UpdateAuthority)` - Update mint authority (update_mint.rs) - `UpdateFreezeAuthority(UpdateAuthority)` - Update freeze authority (update_mint.rs) - - `CreateSplMint(CreateSplMintAction)` - Create SPL mint for cmint (create_spl_mint.rs) - `MintToCToken(MintToCTokenAction)` - Mint to ctoken accounts (mint_to_ctoken.rs) - `UpdateMetadataField(UpdateMetadataFieldAction)` - Update metadata field (update_metadata.rs) - `UpdateMetadataAuthority(UpdateMetadataAuthorityAction)` - Update metadata authority (update_metadata.rs) - `RemoveMetadataKey(RemoveMetadataKeyAction)` - Remove metadata key (update_metadata.rs) - - `DecompressMint(DecompressMintAction)` - Decompress compressed mint to CMint Solana account - - `CompressAndCloseCMint(CompressAndCloseCMintAction)` - Compress and close CMint Solana account + - `DecompressMint(DecompressMintAction)` - Decompress compressed mint to CMint Solana account (decompress_mint.rs) + - `CompressAndCloseCMint(CompressAndCloseCMintAction)` - Compress and close CMint Solana account (compress_and_close_cmint.rs) **Accounts:** 1. light_system_program @@ -72,7 +70,7 @@ Key concepts integrated: Optional accounts (based on configuration): 2. mint_signer - - (signer) - required if create_mint is Some or CreateSplMint action present + - (signer) - required if create_mint is Some or DecompressMint action present - PDA seed for SPL mint creation (seeds from compressed mint randomness) 3. authority @@ -161,13 +159,6 @@ Packed accounts (remaining accounts): - Validate: current authority matches signer - Update: set new authority (can be None to disable) - **CreateSplMint:** - - Validate: mint_signer is provided and signing - - Create: SPL Token 2022 mint account via CPI - - Create: Token pool PDA account - - Initialize: mint with ctoken PDA as mint/freeze authority - - Mint: existing supply to token pool - **MintToCToken:** - Validate: mint authority matches signer - Calculate: sum recipient amounts diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index 5e988aea56..61dfdde529 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -70,13 +70,11 @@ impl<'info> MintActionAccounts<'info> { accounts: &'info [AccountInfo], config: &AccountsConfig, cmint_pubkey: Option<&solana_pubkey::Pubkey>, - token_pool_index: u8, - token_pool_bump: u8, ) -> Result { let mut iter = AccountIterator::new(accounts); let light_system_program = iter.next_account("light_system_program")?; - // mint_signer needs to sign for create_mint/create_spl_mint, but not for decompress_mint + // mint_signer needs to sign for create_mint, but not for decompress_mint let mint_signer = if config.mint_signer_must_sign() { iter.next_option_signer("mint_signer", config.with_mint_signer)? } else { @@ -156,7 +154,7 @@ impl<'info> MintActionAccounts<'info> { accounts: iter.remaining_unchecked()?, }, }; - mint_accounts.validate_accounts(cmint_pubkey, token_pool_index, token_pool_bump)?; + mint_accounts.validate_accounts(cmint_pubkey)?; Ok(mint_accounts) } @@ -304,8 +302,6 @@ impl<'info> MintActionAccounts<'info> { pub fn validate_accounts( &self, cmint_pubkey: Option<&solana_pubkey::Pubkey>, - _token_pool_index: u8, //TODO: remove - _token_pool_bump: u8, ) -> Result<(), ProgramError> { let accounts = self .executing @@ -386,7 +382,7 @@ impl AccountsConfig { } /// Returns true if mint_signer must be a signer. - /// Required for create_mint and create_spl_mint, but NOT for decompress_mint. + /// Required for create_mint, but NOT for decompress_mint. /// decompress_mint only needs mint_signer.key() for PDA derivation. #[inline(always)] pub fn mint_signer_must_sign(&self) -> bool { @@ -418,11 +414,6 @@ impl AccountsConfig { .as_ref() .map(|x| x.first_set_context() || x.set_context()) .unwrap_or_default(); - // An action in this instruction creates a the spl mint corresponding to a compressed mint. - let create_spl_mint = parsed_instruction_data - .actions - .iter() - .any(|action| matches!(action, ZAction::CreateSplMint(_))); // Check if DecompressMint action is present let has_decompress_mint_action = parsed_instruction_data @@ -442,19 +433,14 @@ impl AccountsConfig { return Err(ErrorCode::CannotDecompressAndCloseInSameInstruction.into()); } - // We need mint signer if create mint, create spl mint, or decompress mint. + // We need mint signer if create mint or decompress mint. // CompressAndCloseCMint does NOT need mint_signer - it verifies CMint by compressed_mint.metadata.mint - let with_mint_signer = parsed_instruction_data.create_mint.is_some() - || create_spl_mint - || has_decompress_mint_action; + let with_mint_signer = + parsed_instruction_data.create_mint.is_some() || has_decompress_mint_action; // CMint account needed for sync when mint is already decompressed (metadata flag) // When mint is None, it means CMint is decompressed (data lives in CMint account) let cmint_decompressed = parsed_instruction_data.mint.is_none(); - if cmint_decompressed && create_spl_mint { - return Err(ProgramError::InvalidInstructionData); - } - if write_to_cpi_context { // Must not have any MintToCToken actions let has_mint_to_ctoken_actions = parsed_instruction_data @@ -465,10 +451,6 @@ impl AccountsConfig { msg!("Mint to ctokens not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } - if create_spl_mint { - msg!("Create spl mint not allowed when writing to cpi context"); - return Err(ErrorCode::CpiContextSetNotUsable.into()); - } if has_decompress_mint_action || cmint_decompressed { msg!("Decompress mint not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs index a4c91f0285..2527be3a99 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/process_actions.rs @@ -86,17 +86,6 @@ pub fn process_actions<'a>( compressed_mint.base.freeze_authority = update_action.new_authority.as_ref().map(|a| **a); } - // TODO: Remove CreateSplMint - dead code, never activated - ZAction::CreateSplMint(_create_spl_action) => { - return Err(ErrorCode::MintActionUnsupportedOperation.into()); - // process_create_spl_mint_action( - // create_spl_action, - // validated_accounts, - // &parsed_instruction_data.mint, - // parsed_instruction_data.token_pool_bump, - // )?; - // compressed_mint.metadata.cmint_decompressed = true; - } ZAction::MintToCToken(mint_to_ctoken_action) => { let account_index = mint_to_ctoken_action.account_index as usize; if account_index >= MAX_PACKED_ACCOUNTS { diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs index dfcbf64646..cb4f5e4466 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs @@ -38,13 +38,8 @@ pub fn process_mint_action( .as_ref() .map(|m| m.metadata.mint.into()); // Validate and parse - let validated_accounts = MintActionAccounts::validate_and_parse( - accounts, - &accounts_config, - cmint_pubkey.as_ref(), - parsed_instruction_data.token_pool_index, - parsed_instruction_data.token_pool_bump, - )?; + let validated_accounts = + MintActionAccounts::validate_and_parse(accounts, &accounts_config, cmint_pubkey.as_ref())?; // Get mint data based on source: // 1. Creating new mint: mint data required in instruction diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs index 5e44b3685f..8b2006220e 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs @@ -10,7 +10,7 @@ use light_ctoken_interface::instructions::{ use pinocchio::{account_info::AccountInfo, pubkey::Pubkey}; use spl_pod::solana_msg::msg; -use crate::extensions::MintExtensionChecks; +use crate::{extensions::MintExtensionChecks, MAX_COMPRESSIONS}; /// Decompress-specific inputs from the input compressed account. /// Only required for decompression with CompressedOnly extension. @@ -36,7 +36,7 @@ impl<'a> DecompressCompressOnlyInputs<'a> { pub fn try_extract( compression: &ZCompression, compression_index: usize, - compression_to_input: &[Option; 32], + compression_to_input: &[Option; MAX_COMPRESSIONS], inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, ) -> Result, ProgramError> { diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index 60d237757f..cb4458e47f 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -19,7 +19,7 @@ use crate::{ convert_program_error, transfer_lamports::{multi_transfer_lamports, Transfer}, }, - LIGHT_CPI_SIGNER, MAX_PACKED_ACCOUNTS, + LIGHT_CPI_SIGNER, MAX_COMPRESSIONS, MAX_PACKED_ACCOUNTS, }; pub mod ctoken; @@ -47,13 +47,16 @@ pub fn process_token_compression<'a>( cpi_authority: &AccountInfo, max_top_up: u16, mint_cache: &'a MintExtensionCache, - compression_to_input: &[Option; 32], + compression_to_input: &[Option; MAX_COMPRESSIONS], ) -> Result<(), ProgramError> { if let Some(compressions) = inputs.compressions.as_ref() { - if compressions.len() >= 32 { - // TODO: add meaningful error message - // TODO: use constant instead of 32. - return Err(ProgramError::InvalidInstructionData); + if compressions.len() >= MAX_COMPRESSIONS { + msg!( + "Too many compressions: {} provided, maximum {} allowed", + compressions.len(), + MAX_COMPRESSIONS + ); + return Err(ErrorCode::TooManyCompressionTransfers.into()); } let mut transfer_map = [0u64; MAX_PACKED_ACCOUNTS]; // Initialize budget: +1 allows exact match (total == max_top_up) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs index 9a8ad16266..a04c239ede 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs @@ -20,8 +20,6 @@ pub struct Transfer2Config { pub total_output_lamports: u64, /// No compressed accounts (neither input nor output) - determines system CPI path pub no_compressed_accounts: bool, - /// No output compressed accounts - determines mint extension hotpath - pub no_output_compressed_accounts: bool, // TODO: remove dead code } impl Transfer2Config { @@ -32,7 +30,6 @@ impl Transfer2Config { ) -> Result { let no_compressed_accounts = inputs.in_token_data.is_empty() && inputs.out_token_data.is_empty(); - let no_output_compressed_accounts = inputs.out_token_data.is_empty(); Ok(Self { sol_pool_required: false, sol_decompression_required: false, @@ -45,7 +42,6 @@ impl Transfer2Config { total_input_lamports: 0, total_output_lamports: 0, no_compressed_accounts, - no_output_compressed_accounts, }) } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs index fa85da81e2..bba5abb69b 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs @@ -12,10 +12,10 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use super::check_extensions::{validate_tlv_and_get_frozen, MintExtensionCache}; -use crate::shared::token_input::set_input_compressed_account; +use crate::{shared::token_input::set_input_compressed_account, MAX_COMPRESSIONS}; /// Process input compressed accounts and return compression-to-input lookup. -/// Returns `[Option; 32]` where `compression_to_input[compression_idx] = Some(input_idx)`. +/// Returns `[Option; MAX_COMPRESSIONS]` where `compression_to_input[compression_idx] = Some(input_idx)`. #[profile] #[inline(always)] pub fn set_input_compressed_accounts<'a>( @@ -25,9 +25,9 @@ pub fn set_input_compressed_accounts<'a>( packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, all_accounts: &[AccountInfo], mint_cache: &'a MintExtensionCache, -) -> Result<[Option; 32], ProgramError> { +) -> Result<[Option; MAX_COMPRESSIONS], ProgramError> { // compression_to_input[compression_index] = Some(input_index), None means unset - let mut compression_to_input: [Option; 32] = [None; 32]; + let mut compression_to_input: [Option; MAX_COMPRESSIONS] = [None; MAX_COMPRESSIONS]; for (i, input_data) in inputs.in_token_data.iter().enumerate() { let input_lamports = if let Some(lamports) = inputs.in_lamports.as_ref() { diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index af859dea89..eb8ceff21d 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -178,7 +178,8 @@ pub fn process_ctoken_approve_checked( } // Parse amount and decimals from instruction data - let (amount, decimals) = unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; + let (amount, decimals) = + unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; // SAFETY: accounts.len() >= 4 validated at function entry let source = &accounts[APPROVE_CHECKED_ACCOUNT_SOURCE]; diff --git a/programs/compressed-token/program/src/ctoken/transfer/checked.rs b/programs/compressed-token/program/src/ctoken/transfer/checked.rs index 20cf3863b3..3db6d122cd 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/checked.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/checked.rs @@ -7,7 +7,9 @@ use pinocchio_token_program::processor::{ }; use super::shared::{process_transfer_extensions_transfer_checked, TransferAccounts}; -use crate::shared::{convert_pinocchio_token_error, convert_token_error, owner_validation::check_token_program_owner}; +use crate::shared::{ + convert_pinocchio_token_error, convert_token_error, owner_validation::check_token_program_owner, +}; /// Account indices for CToken transfer_checked instruction /// Note: Different from ctoken_transfer - mint is at index 1 const ACCOUNT_SOURCE: usize = 0; @@ -75,7 +77,8 @@ pub fn process_ctoken_transfer_checked( )?; // Pass the first 9 bytes (amount + decimals) to the SPL transfer_checked processor - let (amount, decimals) = unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; + let (amount, decimals) = + unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; if let Some(extension_decimals) = extension_decimals { if extension_decimals != decimals { diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index 16fda9cb7e..e34bddaf0b 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -34,6 +34,9 @@ pub const LIGHT_CPI_SIGNER: CpiSigner = pub const MAX_ACCOUNTS: usize = 30; pub(crate) const MAX_PACKED_ACCOUNTS: usize = 40; +/// Maximum number of compression operations per instruction. +/// Used for compression_to_input lookup array sizing. +pub(crate) const MAX_COMPRESSIONS: usize = 32; // Custom ctoken instructions start at 100 to skip spl-token program instrutions. // When adding new instructions check anchor discriminators for collisions! @@ -78,11 +81,12 @@ pub enum InstructionType { /// 2. MintTo /// 3. UpdateMintAuthority /// 4. UpdateFreezeAuthority - /// 5. CreateSplMint - /// 6. MintToCToken - /// 7. UpdateMetadataField - /// 8. UpdateMetadataAuthority - /// 9. RemoveMetadataKey + /// 5. MintToCToken + /// 6. UpdateMetadataField + /// 7. UpdateMetadataAuthority + /// 8. RemoveMetadataKey + /// 9. DecompressMint + /// 10. CompressAndCloseCMint MintAction = 103, /// Claim rent for past completed epochs from compressible token account Claim = 104, diff --git a/programs/compressed-token/program/src/shared/mint_to_token_pool.rs b/programs/compressed-token/program/src/shared/mint_to_token_pool.rs index 42c2b80fd8..08cb00f873 100644 --- a/programs/compressed-token/program/src/shared/mint_to_token_pool.rs +++ b/programs/compressed-token/program/src/shared/mint_to_token_pool.rs @@ -10,7 +10,7 @@ use pinocchio::{ use crate::{shared::convert_program_error, LIGHT_CPI_SIGNER}; /// Mint tokens to the token pool using SPL token mint_to instruction. -/// This function is shared between create_spl_mint and mint_to_compressed processors +/// This function is used by mint_to_compressed processors /// to ensure consistent token pool management. #[profile] pub fn mint_to_token_pool( diff --git a/programs/compressed-token/program/src/shared/mod.rs b/programs/compressed-token/program/src/shared/mod.rs index c372db8f9f..3213704178 100644 --- a/programs/compressed-token/program/src/shared/mod.rs +++ b/programs/compressed-token/program/src/shared/mod.rs @@ -14,7 +14,9 @@ pub mod transfer_lamports; pub mod validate_ata_derivation; pub use config_account::{next_config_account, parse_config_account}; -pub use convert_program_error::{convert_pinocchio_token_error, convert_program_error, convert_token_error}; +pub use convert_program_error::{ + convert_pinocchio_token_error, convert_program_error, convert_token_error, +}; pub use create_pda_account::{create_pda_account, verify_pda}; pub use initialize_ctoken_account::create_compressible_account; pub use light_account_checks::AccountIterator; diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index fb704cda37..822bc1b241 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -97,7 +97,6 @@ pub fn set_input_compressed_account<'a>( owner_account }; - // TODO: allow freeze authority to decompress if has CompressOnlyExtension verify_owner_or_delegate_signer( signer_account, delegate_account, diff --git a/programs/compressed-token/program/tests/check_extensions.rs b/programs/compressed-token/program/tests/check_extensions.rs index d588950f76..70a17314e2 100644 --- a/programs/compressed-token/program/tests/check_extensions.rs +++ b/programs/compressed-token/program/tests/check_extensions.rs @@ -8,8 +8,7 @@ //! - Category 5: Direct check_mint_extensions tests use anchor_compressed_token::ErrorCode; -use anchor_lang::prelude::ProgramError; -use anchor_lang::solana_program::pubkey::Pubkey as SolanaPubkey; +use anchor_lang::{prelude::ProgramError, solana_program::pubkey::Pubkey as SolanaPubkey}; use light_account_checks::{ account_info::test_account_info::pinocchio::get_account_info, packed_accounts::ProgramPackedAccounts, @@ -21,7 +20,7 @@ use light_compressed_token::{ use light_ctoken_interface::instructions::{ extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData}, transfer2::{ - Compression, CompressionMode, CompressedTokenInstructionDataTransfer2, + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, MultiInputTokenDataWithContext, MultiTokenTransferOutputData, }, }; @@ -96,11 +95,8 @@ fn create_mock_t22_mint(config: &MintConfig) -> Vec { // Initialize extensions if config.has_pausable { - let ext = mint_state - .init_extension::(true) - .unwrap(); - ext.authority = - OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + let ext = mint_state.init_extension::(true).unwrap(); + ext.authority = OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); ext.paused = PodBool::from(config.is_paused); } @@ -132,14 +128,11 @@ fn create_mock_t22_mint(config: &MintConfig) -> Vec { let ext = mint_state .init_extension::(true) .unwrap(); - ext.delegate = - OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); + ext.delegate = OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); } if config.has_metadata_pointer { - let ext = mint_state - .init_extension::(true) - .unwrap(); + let ext = mint_state.init_extension::(true).unwrap(); ext.metadata_address = OptionalNonZeroPubkey::try_from(Some(SolanaPubkey::new_unique())).unwrap(); } @@ -265,7 +258,9 @@ fn run_build_cache_test( ); let accounts = [mint_account]; - let packed_accounts = ProgramPackedAccounts { accounts: &accounts }; + let packed_accounts = ProgramPackedAccounts { + accounts: &accounts, + }; build_mint_extension_cache(&inputs, &packed_accounts).map(|_| ()) } @@ -426,7 +421,10 @@ fn test_compress_and_close_missing_compressed_only_fails() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert_error(result, ErrorCode::CompressAndCloseMissingCompressedOnlyExtension); + assert_error( + result, + ErrorCode::CompressAndCloseMissingCompressedOnlyExtension, + ); } #[test] @@ -445,7 +443,10 @@ fn test_compress_and_close_empty_tlv_fails() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert_error(result, ErrorCode::CompressAndCloseMissingCompressedOnlyExtension); + assert_error( + result, + ErrorCode::CompressAndCloseMissingCompressedOnlyExtension, + ); } // ============================================================================ @@ -466,7 +467,11 @@ fn test_input_with_restricted_no_outputs_succeeds() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert!(result.is_ok(), "Should succeed with bypass, got {:?}", result); + assert!( + result.is_ok(), + "Should succeed with bypass, got {:?}", + result + ); } #[test] @@ -486,7 +491,11 @@ fn test_compress_and_close_with_compressed_only_succeeds() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert!(result.is_ok(), "Should succeed with CompressedOnly, got {:?}", result); + assert!( + result.is_ok(), + "Should succeed with CompressedOnly, got {:?}", + result + ); } #[test] @@ -504,7 +513,11 @@ fn test_decompress_no_outputs_succeeds() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert!(result.is_ok(), "Should succeed with bypass, got {:?}", result); + assert!( + result.is_ok(), + "Should succeed with bypass, got {:?}", + result + ); } #[test] @@ -522,7 +535,11 @@ fn test_compress_no_outputs_succeeds() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert!(result.is_ok(), "Should succeed with bypass, got {:?}", result); + assert!( + result.is_ok(), + "Should succeed with bypass, got {:?}", + result + ); } // ============================================================================ @@ -555,7 +572,11 @@ fn test_t22_mint_no_extensions_succeeds() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert!(result.is_ok(), "T22 without extensions should succeed, got {:?}", result); + assert!( + result.is_ok(), + "T22 without extensions should succeed, got {:?}", + result + ); } #[test] @@ -572,7 +593,11 @@ fn test_t22_mint_with_metadata_only_succeeds() { }); let result = run_build_cache_test(&inputs, &mint_data, SPL_TOKEN_2022_ID); - assert!(result.is_ok(), "MetadataPointer should succeed, got {:?}", result); + assert!( + result.is_ok(), + "MetadataPointer should succeed, got {:?}", + result + ); } // ============================================================================ @@ -618,7 +643,10 @@ fn test_check_mint_extensions_non_zero_fee() { ); let result = check_mint_extensions(&mint_account, false); - assert_error(result.map(|_| ()), ErrorCode::NonZeroTransferFeeNotSupported); + assert_error( + result.map(|_| ()), + ErrorCode::NonZeroTransferFeeNotSupported, + ); } #[test] @@ -679,7 +707,11 @@ fn test_check_mint_extensions_deny_restricted_non_restricted_succeeds() { // deny_restricted=true should succeed with non-restricted mint let result = check_mint_extensions(&mint_account, true); - assert!(result.is_ok(), "Non-restricted mint should succeed, got {:?}", result); + assert!( + result.is_ok(), + "Non-restricted mint should succeed, got {:?}", + result + ); } #[test] @@ -704,5 +736,9 @@ fn test_check_mint_extensions_valid_mint_succeeds() { // deny_restricted=false with all valid states should succeed let result = check_mint_extensions(&mint_account, false); - assert!(result.is_ok(), "Valid mint should succeed, got {:?}", result); + assert!( + result.is_ok(), + "Valid mint should succeed, got {:?}", + result + ); } diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index 666b336c56..0e548bea9a 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -9,7 +9,7 @@ use light_ctoken_interface::{ instructions::{ extensions::{token_metadata::TokenMetadataInstructionData, ExtensionInstructionData}, mint_action::{ - Action, CompressedMintInstructionData, CpiContext, CreateMint, CreateSplMintAction, + Action, CompressedMintInstructionData, CpiContext, CreateMint, MintActionCompressedInstructionData, MintToCTokenAction, MintToCompressedAction, Recipient, RemoveMetadataKeyAction, UpdateAuthority, UpdateMetadataAuthorityAction, UpdateMetadataFieldAction, @@ -83,12 +83,6 @@ fn random_update_authority_action(rng: &mut StdRng) -> UpdateAuthority { } } -fn random_create_spl_mint_action(rng: &mut StdRng) -> CreateSplMintAction { - CreateSplMintAction { - mint_bump: rng.gen::(), - } -} - fn random_update_metadata_field_action(rng: &mut StdRng) -> UpdateMetadataFieldAction { UpdateMetadataFieldAction { extension_index: rng.gen_range(0..=2) as u8, @@ -114,15 +108,14 @@ fn random_remove_metadata_key_action(rng: &mut StdRng) -> RemoveMetadataKeyActio } fn random_action(rng: &mut StdRng) -> Action { - match rng.gen_range(0..8) { + match rng.gen_range(0..7) { 0 => Action::MintToCompressed(random_mint_to_action(rng)), 1 => Action::UpdateMintAuthority(random_update_authority_action(rng)), 2 => Action::UpdateFreezeAuthority(random_update_authority_action(rng)), - 3 => Action::CreateSplMint(random_create_spl_mint_action(rng)), - 4 => Action::MintToCToken(random_mint_to_decompressed_action(rng)), - 5 => Action::UpdateMetadataField(random_update_metadata_field_action(rng)), - 6 => Action::UpdateMetadataAuthority(random_update_metadata_authority_action(rng)), - 7 => Action::RemoveMetadataKey(random_remove_metadata_key_action(rng)), + 3 => Action::MintToCToken(random_mint_to_decompressed_action(rng)), + 4 => Action::UpdateMetadataField(random_update_metadata_field_action(rng)), + 5 => Action::UpdateMetadataAuthority(random_update_metadata_authority_action(rng)), + 6 => Action::RemoveMetadataKey(random_remove_metadata_key_action(rng)), _ => unreachable!(), } } @@ -231,17 +224,11 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun .iter() .any(|action| matches!(action, Action::MintToCompressed(_))); - // 4. create_spl_mint (for with_mint_signer only) - let create_spl_mint = data - .actions - .iter() - .any(|action| matches!(action, Action::CreateSplMint(_))); - - // 5. cmint_decompressed - only based on metadata flag (matches AccountsConfig::new) + // 4. cmint_decompressed - only based on metadata flag (matches AccountsConfig::new) let cmint_decompressed = data.mint.as_ref().unwrap().metadata.cmint_decompressed; - // 6. with_mint_signer - let with_mint_signer = data.create_mint.is_some() || create_spl_mint; + // 5. with_mint_signer + let with_mint_signer = data.create_mint.is_some(); // 7. create_mint let create_mint = data.create_mint.is_some(); @@ -346,12 +333,6 @@ fn check_if_config_should_error(instruction_data: &MintActionCompressedInstructi .iter() .any(|action| matches!(action, Action::MintToCToken(_))); - // Check for CreateSplMint actions - let create_spl_mint = instruction_data - .actions - .iter() - .any(|action| matches!(action, Action::CreateSplMint(_))); - // Check for MintToCompressed actions let has_mint_to_actions = instruction_data .actions @@ -368,9 +349,8 @@ fn check_if_config_should_error(instruction_data: &MintActionCompressedInstructi // Error conditions matching AccountsConfig::new: // 1. has_mint_to_ctoken (MintToCToken actions not allowed) - // 2. create_spl_mint (CreateSplMint actions not allowed) - // 3. cmint_decompressed && has_mint_to_actions (mint decompressed + MintToCompressed not allowed) - has_mint_to_ctoken || create_spl_mint || (cmint_decompressed && has_mint_to_actions) + // 2. cmint_decompressed && has_mint_to_actions (mint decompressed + MintToCompressed not allowed) + has_mint_to_ctoken || (cmint_decompressed && has_mint_to_actions) } else { false } diff --git a/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs b/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs index 16d54fc7fc..79d85b983a 100644 --- a/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs +++ b/sdk-libs/ctoken-sdk/src/compressed_token/v2/mint_action/account_metas.rs @@ -116,7 +116,7 @@ impl MintActionMetaConfig { } /// Set the mint_signer account with signing required. - /// Use for create_mint and create_spl_mint actions. + /// Use for create_mint actions. pub fn with_mint_signer(mut self, mint_signer: Pubkey) -> Self { self.mint_signer = Some(mint_signer); self.mint_signer_must_sign = true; @@ -158,7 +158,7 @@ impl MintActionMetaConfig { // mint_signer is present when creating a new mint or decompressing if let Some(mint_signer) = self.mint_signer { - // mint_signer needs to sign for create_mint/create_spl_mint, not for decompress_mint + // mint_signer needs to sign for create_mint, not for decompress_mint metas.push(AccountMeta::new_readonly( mint_signer, self.mint_signer_must_sign, diff --git a/sdk-libs/token-client/src/actions/mint_action.rs b/sdk-libs/token-client/src/actions/mint_action.rs index 5e2f59127a..e08329d0ef 100644 --- a/sdk-libs/token-client/src/actions/mint_action.rs +++ b/sdk-libs/token-client/src/actions/mint_action.rs @@ -23,7 +23,7 @@ use crate::instructions::mint_action::{ /// * `params` - Parameters for the mint action /// * `authority` - Authority keypair for the mint operations /// * `payer` - Account that pays for the transaction -/// * `mint_signer` - Optional mint signer for CreateSplMint action +/// * `mint_signer` - Optional mint signer for create_mint or DecompressMint action pub async fn mint_action( rpc: &mut R, params: MintActionParams, @@ -49,7 +49,7 @@ pub async fn mint_action( signers.push(authority); } - // Add mint signer if needed for CreateSplMint + // Add mint signer if needed for create_mint or DecompressMint if let Some(signer) = mint_signer { if !signers.iter().any(|s| s.pubkey() == signer.pubkey()) { signers.push(signer); From ff1f49b7725d7819c9b74f22b74a2ae6534441d2 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 01:14:08 +0000 Subject: [PATCH 13/38] refactor: validate CompressibleConfig state and simplify mint_output - Add config state validation (active required) during account parsing - Change compressible_config field to store parsed CompressibleConfig - Remove redundant config parsing in decompress_mint action - Refactor mint_output into serialize_decompressed_mint and serialize_compressed_mint helpers - Use cmint_decompressed flag directly after process_actions --- .../compressed_token/mint_action/accounts.rs | 16 +- .../mint_action/actions/decompress_mint.rs | 7 +- .../mint_action/mint_output.rs | 207 +++++++++--------- 3 files changed, 116 insertions(+), 114 deletions(-) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index 61dfdde529..b4727d6c15 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -1,6 +1,7 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::solana_program::program_error::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; +use light_compressible::config::CompressibleConfig; use light_ctoken_interface::{ instructions::mint_action::{ZAction, ZMintActionCompressedInstructionData}, CMINT_ADDRESS_TREE, @@ -12,7 +13,7 @@ use spl_pod::solana_msg::msg; use crate::shared::{ accounts::{CpiContextLightSystemAccounts, LightSystemAccounts}, - AccountIterator, + next_config_account, AccountIterator, }; pub struct MintActionAccounts<'info> { @@ -41,8 +42,8 @@ pub struct MintActionAccounts<'info> { /// Required accounts to execute an instruction /// with or without cpi context. pub struct ExecutingAccounts<'info> { - /// CompressibleConfig account - required when creating CMint (always compressible). - pub compressible_config: Option<&'info AccountInfo>, + /// CompressibleConfig - parsed and validated (active state) when creating CMint. + pub compressible_config: Option<&'info CompressibleConfig>, /// CMint Solana account (decompressed compressed mint). /// Required for DecompressMint action and when syncing with existing CMint. pub cmint: Option<&'info AccountInfo>, @@ -99,9 +100,12 @@ impl<'info> MintActionAccounts<'info> { packed_accounts: ProgramPackedAccounts { accounts: &[] }, }) } else { - // Parse compressible config when creating or closing CMint - let compressible_config = - iter.next_option("compressible_config", config.needs_compressible_accounts())?; + // Parse and validate compressible config when creating or closing CMint + let compressible_config = if config.needs_compressible_accounts() { + Some(next_config_account(&mut iter)?) + } else { + None + }; // CMint account required if already decompressed (for sync) OR being decompressed/closed let cmint = iter.next_option_mut("cmint", config.needs_cmint_account())?; diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index 2a3838b7bf..1d224a27cf 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -18,7 +18,6 @@ use crate::{ shared::{ convert_program_error, create_pda_account::{create_pda_account, verify_pda}, - parse_config_account, }, }; @@ -75,13 +74,11 @@ pub fn process_decompress_mint_action( .cmint .ok_or(ErrorCode::MintActionMissingCMintAccount)?; - // 3. Get and validate CompressibleConfig account - let config_account = executing + // 3. Get CompressibleConfig (already parsed and validated as active) + let config = executing .compressible_config .ok_or(ErrorCode::MissingCompressibleConfig)?; - let config = parse_config_account(config_account)?; - // 5. Validate write_top_up doesn't exceed max_top_up if action.write_top_up > config.rent_config.max_top_up as u32 { msg!( diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 1c417186e5..6e8d713052 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -44,103 +44,47 @@ pub fn process_output_compressed_account<'a>( &validated_accounts.packed_accounts, &mut compressed_mint, )?; - // When decompressed (CMint is source of truth), use zero values - // TODO: check whether I can just mutate is_decompressed instead of using has_compress_and_close_cmint_action - // TODO: double check that we cannot close and create in the same instruction - let cmint_is_decompressed = accounts_config.cmint_is_decompressed(); - // Serialize state into CMint solana account - // SKIP if CompressAndCloseCMint action is present (CMint is being closed) - // SKIP if DecompressMint action is present (CMint is being closed) - if cmint_is_decompressed { - let cmint_account = validated_accounts - .get_cmint() - .ok_or(ErrorCode::CMintNotFound)?; - if !accounts_config.has_compress_and_close_cmint_action { - let num_bytes = cmint_account.data_len() as u64; - let current_lamports = cmint_account.lamports(); - let rent_exemption = get_rent_exemption_lamports(num_bytes) - .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; - - // Skip top-up calculation if decompress mint action is present. - if !accounts_config.has_decompress_mint_action { - // Handle top-up for compressed mint (compression info is now embedded directly) - // Get current slot for top-up calculation - let current_slot = Clock::get() - .map_err(|_| ProgramError::UnsupportedSysvar)? - .slot; - // Calculate top-up amount using embedded compression info - let top_up = compressed_mint - .compression - .calculate_top_up_lamports( - num_bytes, - current_slot, - current_lamports, - rent_exemption, - ) - .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; - - if top_up > 0 { - let fee_payer = validated_accounts - .executing - .as_ref() - .map(|exec| exec.system.fee_payer) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - transfer_lamports(top_up, fee_payer, cmint_account) - .map_err(convert_program_error)?; - } - } - - let serialized = compressed_mint - .try_to_vec() - .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)?; - let required_size = serialized.len(); - - // Resize if needed (e.g., metadata extensions added) - if cmint_account.data_len() < required_size { - cmint_account - .resize(required_size) - .map_err(|_| ErrorCode::CMintResizeFailed)?; - - // Transfer additional lamports for rent if resized - let rent = Rent::get().map_err(|_| ProgramError::UnsupportedSysvar)?; - let required_lamports = rent.minimum_balance(required_size); - if cmint_account.lamports() < required_lamports { - let fee_payer = validated_accounts - .executing - .as_ref() - .map(|exec| exec.system.fee_payer) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - transfer_lamports( - required_lamports - cmint_account.lamports(), - fee_payer, - cmint_account, - ) - .map_err(convert_program_error)?; - } - } - - let mut cmint_data = cmint_account - .try_borrow_mut_data() - .map_err(|_| ProgramError::AccountBorrowFailed)?; - if cmint_data.len() < serialized.len() { - msg!( - "CMint account too small: {} < {}", - cmint_data.len(), - serialized.len() - ); - return Err(ErrorCode::CMintResizeFailed.into()); - } - cmint_data[..serialized.len()].copy_from_slice(&serialized); - } + + if compressed_mint.metadata.cmint_decompressed { + serialize_decompressed_mint(validated_accounts, accounts_config, &mut compressed_mint)?; + } + + serialize_compressed_mint( + mint_account, + compressed_mint, + parsed_instruction_data, + queue_indices, + ) +} + +#[inline(always)] +fn split_mint_and_token_accounts<'a>( + output_compressed_accounts: &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], +) -> ( + &'a mut ZOutputCompressedAccountWithPackedContextMut<'a>, + &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], +) { + if output_compressed_accounts.len() == 1 { + (&mut output_compressed_accounts[0], &mut []) + } else { + let (mint_account, token_accounts) = output_compressed_accounts.split_at_mut(1); + (&mut mint_account[0], token_accounts) } +} +fn serialize_compressed_mint<'a>( + mint_account: &'a mut ZOutputCompressedAccountWithPackedContextMut<'a>, + compressed_mint: CompressedMint, + parsed_instruction_data: &ZMintActionCompressedInstructionData, + queue_indices: &QueueIndices, +) -> Result<(), ProgramError> { let compressed_account_data = mint_account .compressed_account .data .as_mut() .ok_or(ErrorCode::MintActionOutputSerializationFailed)?; - let (discriminator, data_hash) = if cmint_is_decompressed { + let (discriminator, data_hash) = if compressed_mint.metadata.cmint_decompressed { if !compressed_account_data.data.is_empty() { msg!( "Data allocation for output mint account is wrong: {} (expected) != {} ", @@ -170,13 +114,13 @@ pub fn process_output_compressed_account<'a>( compressed_account_data .data .copy_from_slice(data.as_slice()); + ( COMPRESSED_MINT_DISCRIMINATOR, Sha256BE::hash(compressed_account_data.data)?, ) }; - // Set mint output compressed account fields except the data. mint_account.set( crate::LIGHT_CPI_SIGNER.program_id.into(), 0, @@ -185,21 +129,78 @@ pub fn process_output_compressed_account<'a>( discriminator, data_hash, )?; - Ok(()) } -#[inline(always)] -fn split_mint_and_token_accounts<'a>( - output_compressed_accounts: &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], -) -> ( - &'a mut ZOutputCompressedAccountWithPackedContextMut<'a>, - &'a mut [ZOutputCompressedAccountWithPackedContextMut<'a>], -) { - if output_compressed_accounts.len() == 1 { - (&mut output_compressed_accounts[0], &mut []) - } else { - let (mint_account, token_accounts) = output_compressed_accounts.split_at_mut(1); - (&mut mint_account[0], token_accounts) +fn serialize_decompressed_mint( + validated_accounts: &MintActionAccounts, + accounts_config: &AccountsConfig, + compressed_mint: &mut CompressedMint, +) -> Result<(), ProgramError> { + let cmint_account = validated_accounts + .get_cmint() + .ok_or(ErrorCode::CMintNotFound)?; + let num_bytes = cmint_account.data_len() as u64; + let current_lamports = cmint_account.lamports(); + let rent_exemption = get_rent_exemption_lamports(num_bytes) + .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; + + // Skip top-up calculation if decompress mint action is present + // (rent was just paid during account creation). + if !accounts_config.has_decompress_mint_action { + // Handle top-up for compressed mint (compression info is now embedded directly) + // Get current slot for top-up calculation + let current_slot = Clock::get().map_err(convert_program_error)?.slot; + // Calculate top-up amount using embedded compression info + let top_up = compressed_mint + .compression + .calculate_top_up_lamports(num_bytes, current_slot, current_lamports, rent_exemption) + .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; + + if top_up > 0 { + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + transfer_lamports(top_up, fee_payer, cmint_account).map_err(convert_program_error)?; + } } + + let serialized = compressed_mint + .try_to_vec() + .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)?; + let required_size = serialized.len(); + + // Resize if needed (e.g., metadata extensions added) + if cmint_account.data_len() < required_size { + cmint_account + .resize(required_size) + .map_err(|_| ErrorCode::CMintResizeFailed)?; + + // Transfer additional lamports for rent if resized + let rent = Rent::get().map_err(|_| ProgramError::UnsupportedSysvar)?; + let required_lamports = rent.minimum_balance(required_size); + if cmint_account.lamports() < required_lamports { + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + transfer_lamports( + required_lamports - cmint_account.lamports(), + fee_payer, + cmint_account, + ) + .map_err(convert_program_error)?; + } + } + + let mut cmint_data = cmint_account + .try_borrow_mut_data() + .map_err(|_| ProgramError::AccountBorrowFailed)?; + // SAFETY: we previously resized the account if needed + cmint_data[..serialized.len()].copy_from_slice(&serialized); + + Ok(()) } From 4abf946a14d0bbe27c82556bf5e3d5c4f48aa208 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 01:38:38 +0000 Subject: [PATCH 14/38] fix comments --- .../actions/compress_and_close_cmint.rs | 3 +-- .../mint_action/actions/decompress_mint.rs | 27 ++++++++++--------- .../mint_action/actions/mint_to.rs | 11 ++------ .../mint_action/actions/mint_to_ctoken.rs | 3 +-- .../mint_action/actions/update_metadata.rs | 2 +- .../mint_action/mint_input.rs | 1 - .../mint_action/mint_output.rs | 7 +---- .../compressed_token/mint_action/processor.rs | 2 +- .../mint_action/zero_copy_config.rs | 2 +- .../compressed_token/transfer2/accounts.rs | 4 +-- .../transfer2/change_account.rs | 2 +- .../ctoken/compress_or_decompress_ctokens.rs | 4 +-- .../src/compressed_token/transfer2/cpi.rs | 3 +-- .../compressed_token/transfer2/processor.rs | 11 ++++---- .../transfer2/token_outputs.rs | 2 +- .../program/src/compressible/claim.rs | 2 +- .../src/compressible/withdraw_funding_pool.rs | 4 +-- .../program/src/ctoken/close/processor.rs | 2 +- .../program/src/ctoken/transfer/shared.rs | 4 +-- .../program/src/extensions/processor.rs | 1 - programs/compressed-token/program/src/lib.rs | 2 +- .../program/src/shared/accounts.rs | 12 ++++----- .../program/src/shared/compressible_top_up.rs | 3 +-- .../program/src/shared/cpi.rs | 2 +- .../src/shared/initialize_ctoken_account.rs | 2 +- .../program/src/shared/owner_validation.rs | 2 +- 26 files changed, 51 insertions(+), 69 deletions(-) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs index 46f6249db2..d2d1a1004a 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs @@ -71,10 +71,9 @@ pub fn process_compress_and_close_cmint_action( return Err(ErrorCode::InvalidCMintAccount.into()); } - // 4. Access compression info directly (all cmints now have embedded compression) let compression_info = &compressed_mint.compression; - // 5. Verify rent_sponsor matches compression info + // 4. Verify rent_sponsor matches compression info if !pubkey_eq(rent_sponsor.key(), &compression_info.rent_sponsor) { msg!("Rent sponsor does not match compression info"); return Err(ErrorCode::InvalidRentSponsor.into()); diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index 1d224a27cf..06c363aff0 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -59,7 +59,7 @@ pub fn process_decompress_mint_action( return Err(ErrorCode::CMintAlreadyExists.into()); } - // rent_payment == 1 is rejected - epoch boundary edge case + // 2. rent_payment == 1 is rejected - epoch boundary edge case if action.rent_payment == 1 { msg!("Prefunding for exactly 1 epoch is not allowed. Use 0 or 2+ epochs."); return Err(ErrorCode::OneEpochPrefundingNotAllowed.into()); @@ -79,7 +79,7 @@ pub fn process_decompress_mint_action( .compressible_config .ok_or(ErrorCode::MissingCompressibleConfig)?; - // 5. Validate write_top_up doesn't exceed max_top_up + // 4. Validate write_top_up doesn't exceed max_top_up if action.write_top_up > config.rent_config.max_top_up as u32 { msg!( "write_top_up {} exceeds max_top_up {}", @@ -89,7 +89,7 @@ pub fn process_decompress_mint_action( return Err(ErrorCode::WriteTopUpExceedsMaximum.into()); } - // 6. Get rent_sponsor and verify it matches config + // Get rent_sponsor and verify it matches config let rent_sponsor = executing .rent_sponsor .ok_or(ErrorCode::MissingRentSponsor)?; @@ -99,12 +99,12 @@ pub fn process_decompress_mint_action( return Err(ErrorCode::InvalidRentSponsor.into()); } - // 7. Get current slot for last_claimed_slot + // Get current slot for last_claimed_slot let current_slot = Clock::get() .map_err(|_| ProgramError::UnsupportedSysvar)? .slot; - // 8. Set compression info directly on compressed_mint (all cmints now have embedded compression) + // 5. Set compression info on compressed_mint compressed_mint.compression = CompressionInfo { config_account_version: config.version, compress_to_pubkey: 0, // Not applicable for CMint @@ -122,7 +122,7 @@ pub fn process_decompress_mint_action( }, }; - // 9. Verify PDA derivation + // 6. Verify PDA derivation let seeds: [&[u8]; 2] = [COMPRESSED_MINT_SEED, mint_signer.key()]; verify_pda( cmint.key(), @@ -131,17 +131,18 @@ pub fn process_decompress_mint_action( &crate::LIGHT_CPI_SIGNER.program_id, )?; - // 10. Calculate account size AFTER adding extension (using borsh serialization) + // 7. Account creation: rent_sponsor pays rent exemption, fee_payer pays Light rent + // 7a. Calculate account size AFTER adding extension (using borsh serialization) let account_size = borsh::to_vec(compressed_mint) .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)? .len(); - // 11. Calculate Light Protocol rent (base_rent + bytes * lamports_per_byte * epochs + compression_cost) + // 7b. Calculate Light Protocol rent (base_rent + bytes * lamports_per_byte * epochs + compression_cost) let light_rent = config .rent_config .get_rent_with_compression_cost(account_size as u64, action.rent_payment as u64); - // 12. Build seeds for rent_sponsor PDA (to sign the transfer) + // 7c. Build seeds for rent_sponsor PDA (to sign the transfer) let version_bytes = config.version.to_le_bytes(); let rent_sponsor_bump_bytes = [config.rent_sponsor_bump]; let rent_sponsor_seeds = [ @@ -150,7 +151,7 @@ pub fn process_decompress_mint_action( Seed::from(rent_sponsor_bump_bytes.as_ref()), ]; - // 13. Build seeds for CMint PDA + // 7d. Build seeds for CMint PDA let cmint_bump_bytes = [action.cmint_bump]; let cmint_seeds = [ Seed::from(COMPRESSED_MINT_SEED), @@ -158,7 +159,7 @@ pub fn process_decompress_mint_action( Seed::from(cmint_bump_bytes.as_ref()), ]; - // 14. Create CMint PDA account + // 7e. Create CMint PDA account // rent_sponsor pays ONLY the rent exemption (minimum_balance) // additional_lamports = None means create_pda_account only pays rent exemption create_pda_account( @@ -170,7 +171,7 @@ pub fn process_decompress_mint_action( None, // rent_sponsor pays ONLY rent exemption )?; - // 15. fee_payer pays the Light Protocol rent + // 7f. fee_payer pays the Light Protocol rent Transfer { from: fee_payer, to: cmint, @@ -179,7 +180,7 @@ pub fn process_decompress_mint_action( .invoke() .map_err(convert_program_error)?; - // 16. Set the cmint_decompressed flag + // 8. Set the cmint_decompressed flag compressed_mint.metadata.cmint_decompressed = true; Ok(()) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs index 9ec58c163c..1f096e686c 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to.rs @@ -18,15 +18,8 @@ use crate::{ /// ## Process Steps /// 1. **Authority Validation**: Verify signer matches current mint authority from compressed mint state /// 2. **Amount Calculation**: Sum recipient amounts with overflow protection -/// 3. **Lamports Calculation**: Calculate total lamports for compressed accounts (if specified) -/// 4. **Supply Update**: Calculate new total supply with overflow protection -/// 5. **SPL Mint Synchronization**: For initialized SPL mints, validate accounts and mint equivalent tokens to token pool via CPI -/// 6. **Compressed Account Creation**: Create new compressed token account for each recipient -/// -/// ## SPL Mint Synchronization -/// When `compressed_mint.metadata.cmint_decompressed` is true and an SPL mint exists for this compressed mint, -/// the function maintains consistency between the compressed token supply and the underlying SPL mint supply -/// by minting equivalent tokens to a program-controlled token pool account via CPI to SPL Token 2022. +/// 3. **Supply Update**: Calculate new total supply with overflow protection +/// 4. **Compressed Account Creation**: Create new compressed token account for each recipient #[allow(clippy::too_many_arguments)] #[profile] pub fn process_mint_to_compressed_action<'a>( diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs index 03ce5c21eb..42c16fd356 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/mint_to_ctoken.rs @@ -41,8 +41,7 @@ pub fn process_mint_to_ctoken_action( let token_account_info = 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 + // Authority check performed above - proceed with minting to CToken account let inputs = CTokenCompressionInputs::mint_ctokens( amount, mint.to_bytes(), diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs index d15bd9d840..7738b17be2 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/update_metadata.rs @@ -122,7 +122,7 @@ pub fn process_update_metadata_authority_action( Ok(()) } -/// Only checks authority, the key is removed during data allocation. +/// Removes a metadata key from additional_metadata after authority check. #[profile] pub fn process_remove_metadata_key_action( action: &ZRemoveMetadataKeyAction, diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs index 80af25aac1..0f9f8a23a1 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs @@ -40,7 +40,6 @@ pub fn create_input_compressed_mint_account( .mint .as_ref() .ok_or(ProgramError::InvalidInstructionData)?; - // Return it so that we dont deserialize it twice. let compressed_mint = CompressedMint::try_from(mint_data)?; let bytes = compressed_mint .try_to_vec() diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 6e8d713052..9831c3bc3c 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -22,7 +22,7 @@ use crate::{ shared::{convert_program_error, transfer_lamports::transfer_lamports}, }; -/// Processes the output compressed mint account and returns the modified mint for CMint sync. +/// Processes the output compressed mint account, syncing to CMint if decompressed. #[profile] pub fn process_output_compressed_account<'a>( parsed_instruction_data: &ZMintActionCompressedInstructionData, @@ -97,7 +97,6 @@ fn serialize_compressed_mint<'a>( // of a closed compressed account without any data. ([0u8; 8], [0u8; 32]) } else { - // Serialize compressed mint for compressed account let data = compressed_mint .try_to_vec() .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; @@ -110,7 +109,6 @@ fn serialize_compressed_mint<'a>( return Err(ProgramError::InvalidAccountData); } - // Copy data and compute hash compressed_account_data .data .copy_from_slice(data.as_slice()); @@ -148,10 +146,7 @@ fn serialize_decompressed_mint( // Skip top-up calculation if decompress mint action is present // (rent was just paid during account creation). if !accounts_config.has_decompress_mint_action { - // Handle top-up for compressed mint (compression info is now embedded directly) - // Get current slot for top-up calculation let current_slot = Clock::get().map_err(convert_program_error)?.slot; - // Calculate top-up amount using embedded compression info let top_up = compressed_mint .compression .calculate_top_up_lamports(num_bytes, current_slot, current_lamports, rent_exemption) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs index cb4f5e4466..5ba9cdf883 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs @@ -150,7 +150,7 @@ pub fn process_mint_action( false, // no sol_pool_pda None, executing.system.cpi_context.map(|x| *x.key()), - false, // write to cpi context account + false, // don't write to cpi context account ) } else { if validated_accounts.write_to_cpi_context_system.is_none() { diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs index ecef2e7ebb..9ed12f3e53 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs @@ -47,7 +47,7 @@ pub fn get_zero_copy_configs( ZAction::UpdateFreezeAuthority(_) => {} ZAction::RemoveMetadataKey(_) => {} ZAction::UpdateMetadataAuthority(auth_action) => { - // Update output config for authority revocation + // Validate extension index for authority revocation if auth_action.new_authority.to_bytes() == [0u8; 32] { let extension_index = auth_action.extension_index as usize; if extension_index >= output_extensions_config.len() { diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs b/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs index 7a46e5bf5b..1eb0ecd67c 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/accounts.rs @@ -105,7 +105,7 @@ impl<'info> Transfer2Accounts<'info> { } /// Extract CPI accounts slice for light-system-program invocation - /// Includes static accounts + tree accounts based on highest tree index + /// Includes static accounts + tree accounts identified by owner /// Returns (cpi_accounts_slice, tree_accounts) #[profile] #[inline(always)] @@ -137,7 +137,7 @@ impl<'info> Transfer2Accounts<'info> { } } -/// Extract tree accounts by finding the highest tree index and using it as closing offset +/// Extract tree accounts by checking account owner matches account-compression program #[profile] #[inline(always)] pub fn extract_tree_accounts<'info>( diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs b/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs index 3b35522c4d..e65ea661bb 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/change_account.rs @@ -1,4 +1,4 @@ -//! unused +//! Change account handling for lamports differences in Transfer2 use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index 1c5f61f478..2270a717d4 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -84,8 +84,8 @@ pub fn compress_or_decompress_ctokens( ZCompressionMode::Decompress => { apply_decompress_extension_state(&mut ctoken, token_account_info, decompress_inputs)?; - // Decompress: add to solana account - // Update the balance in the compressed token account + // Decompress: add to CToken account + // Update the balance in the CToken solana account ctoken.base.amount.set( current_balance .checked_add(amount) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs b/programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs index aa06a1671d..98498171f0 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/cpi.rs @@ -67,10 +67,9 @@ pub fn allocate_cpi_bytes( output_accounts.push((false, data_len)); // Token accounts don't have addresses } - // Add extra output account for change account if needed (no delegate, no token data) + // Add extra output account for change account if needed (no delegate) if inputs.with_lamports_change_account_merkle_tree_index != 0 { output_accounts.push((false, compressed_token_data_len(false))); - // No delegate } let mut input_accounts = ArrayVec::new(); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 8e7597b029..8c9b2800bc 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -37,12 +37,11 @@ use crate::{ /// 1. Unpack compressed input accounts and input token data, this uses /// standardized signer / delegate and will fail in proof verification in /// case either is invalid. -/// 2. Check that compressed accounts are of same mint. -/// 3. Check that sum of input compressed accounts is equal to sum of output -/// compressed accounts -/// 4. create_output_compressed_accounts -/// 5. Serialize and add token_data data to in compressed_accounts. -/// 6. Invoke light_system_program::execute_compressed_transaction. +/// 2. Check that sum of input compressed accounts equals sum of output +/// compressed accounts (supports multi-mint) +/// 3. create_output_compressed_accounts +/// 4. Serialize and add token_data data to in compressed_accounts. +/// 5. Invoke light_system_program::execute_compressed_transaction. #[profile] pub fn process_transfer2( accounts: &[AccountInfo], diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs index 17d65942bf..ab6b2ec6d0 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/token_outputs.rs @@ -13,7 +13,7 @@ use pinocchio::account_info::AccountInfo; use super::check_extensions::validate_tlv_and_get_frozen; use crate::shared::token_output::set_output_compressed_account; -/// Process output compressed accounts and return total output lamports +/// Process output compressed accounts #[profile] #[inline(always)] pub fn set_output_compressed_accounts<'a>( diff --git a/programs/compressed-token/program/src/compressible/claim.rs b/programs/compressed-token/program/src/compressible/claim.rs index 1518884ff5..5bfed2337b 100644 --- a/programs/compressed-token/program/src/compressible/claim.rs +++ b/programs/compressed-token/program/src/compressible/claim.rs @@ -39,7 +39,7 @@ impl<'a> ClaimAccounts<'a> { .map_err(ProgramError::from)?; if *config_account.compression_authority.as_array() != *compression_authority.key() { - msg!("invalid rent authority"); + msg!("invalid compression authority"); return Err(ErrorCode::InvalidCompressAuthority.into()); } if *config_account.rent_sponsor.as_array() != *rent_sponsor.key() { diff --git a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs index 0a54c94eb9..c4be07dd83 100644 --- a/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs +++ b/programs/compressed-token/program/src/compressible/withdraw_funding_pool.rs @@ -71,7 +71,7 @@ pub fn process_withdraw_funding_pool( account_infos: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - // Parse instruction data: [bump: u8][amount: u64] + // Parse instruction data: [amount: u64] if instruction_data.len() < 8 { msg!("Invalid instruction data length"); return Err(ProgramError::InvalidInstructionData); @@ -98,7 +98,7 @@ pub fn process_withdraw_funding_pool( return Err(ProgramError::InsufficientFunds); } - // Prepare seeds for invoke_signed - the pool PDA is derived from [b"pool", compression_authority] + // Prepare seeds for invoke_signed - rent_sponsor PDA is derived from [b"rent_sponsor", version, bump] let bump_bytes = [rent_sponsor_bump]; let seed_array = [ Seed::from(b"rent_sponsor".as_slice()), diff --git a/programs/compressed-token/program/src/ctoken/close/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs index 3ce04f9a16..e9c36c9fd3 100644 --- a/programs/compressed-token/program/src/ctoken/close/processor.rs +++ b/programs/compressed-token/program/src/ctoken/close/processor.rs @@ -32,7 +32,7 @@ pub fn process_close_token_account( } /// Validates that a ctoken solana account is ready to be closed. -/// The rent authority cannot close the account. +/// Only the owner or close_authority can close the account. #[profile] pub fn validate_token_account_close_instruction( accounts: &CloseTokenAccountAccounts, diff --git a/programs/compressed-token/program/src/ctoken/transfer/shared.rs b/programs/compressed-token/program/src/ctoken/transfer/shared.rs index 3b2ec322fb..8b1d4f5819 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/shared.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/shared.rs @@ -51,7 +51,7 @@ pub struct TransferAccounts<'a> { } /// Process transfer extensions for CTokenTransfer instruction. -/// Restricted extensions are NOT allowed (and will fail anyway due to missing mint). +/// Restricted extensions are NOT allowed (requires mint account which is not provided). #[inline(always)] #[profile] pub fn process_transfer_extensions_transfer( @@ -78,7 +78,7 @@ pub fn process_transfer_extensions_transfer_checked( /// /// # Arguments /// * `transfer_accounts` - Account references for source, destination, authority, and optional mint -/// * `max_top_up` - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) +/// * `max_top_up` - Maximum lamports for top-up. Transaction fails if exceeded. (0 = no limit) /// * `deny_restricted_extensions` - If true, reject source accounts with restricted T22 extensions /// /// Returns: diff --git a/programs/compressed-token/program/src/extensions/processor.rs b/programs/compressed-token/program/src/extensions/processor.rs index 2083ef34a6..097c26e0d1 100644 --- a/programs/compressed-token/program/src/extensions/processor.rs +++ b/programs/compressed-token/program/src/extensions/processor.rs @@ -8,7 +8,6 @@ use light_program_profiler::profile; use crate::extensions::token_metadata::create_output_token_metadata; /// Set extensions state in output compressed account. -/// Compute extensions hash chain. #[inline(always)] #[profile] pub fn extensions_state_in_output_compressed_account( diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index e34bddaf0b..232e0b9a6c 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -38,7 +38,7 @@ pub(crate) const MAX_PACKED_ACCOUNTS: usize = 40; /// Used for compression_to_input lookup array sizing. pub(crate) const MAX_COMPRESSIONS: usize = 32; -// Custom ctoken instructions start at 100 to skip spl-token program instrutions. +// Instruction discriminators use SPL Token values (3-18) for compatibility plus custom values (100+). // When adding new instructions check anchor discriminators for collisions! #[repr(u8)] pub enum InstructionType { diff --git a/programs/compressed-token/program/src/shared/accounts.rs b/programs/compressed-token/program/src/shared/accounts.rs index 14e2e2a8eb..4e4cb9334c 100644 --- a/programs/compressed-token/program/src/shared/accounts.rs +++ b/programs/compressed-token/program/src/shared/accounts.rs @@ -33,17 +33,17 @@ pub struct LightSystemAccounts<'info> { pub cpi_authority_pda: &'info AccountInfo, /// Registered program PDA (index 2) - non-mutable pub registered_program_pda: &'info AccountInfo, - /// Account compression authority (index 4) - non-mutable + /// Account compression authority (index 3) - non-mutable pub account_compression_authority: &'info AccountInfo, - /// Account compression program (index 5) - non-mutable + /// Account compression program (index 4) - non-mutable pub account_compression_program: &'info AccountInfo, - /// System program (index 9) - non-mutable + /// System program (index 5) - non-mutable pub system_program: &'info AccountInfo, - /// Sol pool PDA (index 7) - optional, mutable if present + /// Sol pool PDA (index 6) - optional, mutable if present pub sol_pool_pda: Option<&'info AccountInfo>, - /// SOL decompression recipient (index 8) - optional, mutable, for SOL decompression + /// SOL decompression recipient (index 7) - optional, mutable, for SOL decompression pub sol_decompression_recipient: Option<&'info AccountInfo>, - /// CPI context account (index 10) - optional, non-mutable + /// CPI context account (index 8) - optional, mutable pub cpi_context: Option<&'info AccountInfo>, } diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index 3a1f732732..bd57477bb0 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -15,7 +15,7 @@ use super::{ }; /// Calculate and execute top-up transfers for compressible CMint and CToken accounts. -/// Both accounts are optional - if an account doesn't have compressible extension, it's skipped. +/// CMint always has compression info. CToken requires Compressible extension or errors. /// /// # Arguments /// * `cmint` - The CMint account (may or may not have Compressible extension) @@ -99,7 +99,6 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( } /// Process compression top-up using embedded compression info. -/// All ctoken accounts now have compression info embedded directly in meta. #[inline(always)] pub fn process_compression_top_up( compression: &T, diff --git a/programs/compressed-token/program/src/shared/cpi.rs b/programs/compressed-token/program/src/shared/cpi.rs index e7e407a463..d1efec5576 100644 --- a/programs/compressed-token/program/src/shared/cpi.rs +++ b/programs/compressed-token/program/src/shared/cpi.rs @@ -130,7 +130,7 @@ pub fn execute_cpi_invoke( Ok(()) } -/// Eqivalent to pinocchio::cpi::slice_invoke_signed except: +/// Equivalent to pinocchio::cpi::slice_invoke_signed except: /// 1. account_infos: &[&AccountInfo] -> &[AccountInfo] /// 2. Error prints #[inline] 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 162a6fd755..790d4996b4 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -197,7 +197,7 @@ pub fn initialize_ctoken_account( let mut token_account_data = AccountInfoTrait::try_borrow_mut_data(token_account_info)?; // Use new_zero_copy to initialize the token account - // This sets mint, owner, state, compression_only, account_type, and extensions + // This sets mint, owner, state, account_type, and extensions let (mut ctoken, _) = CToken::new_zero_copy(&mut token_account_data, zc_config).map_err(|e| { msg!("Failed to initialize CToken: {:?}", e); diff --git a/programs/compressed-token/program/src/shared/owner_validation.rs b/programs/compressed-token/program/src/shared/owner_validation.rs index ea0e5a42e1..2903f8a9f1 100644 --- a/programs/compressed-token/program/src/shared/owner_validation.rs +++ b/programs/compressed-token/program/src/shared/owner_validation.rs @@ -108,6 +108,6 @@ pub fn check_ctoken_owner( } } - // Authority is neither owner, account delegate, nor permanent delegate + // Authority is neither owner nor permanent delegate Err(ErrorCode::OwnerMismatch.into()) } From 4e4a7098f96ea8a4372fc91cc6002137068324d2 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 01:44:56 +0000 Subject: [PATCH 15/38] fix feedback --- programs/compressed-token/anchor/src/lib.rs | 2 ++ programs/compressed-token/program/docs/ctoken/MINT_TO.md | 2 +- .../program/src/compressed_token/mint_action/mint_output.rs | 4 ++-- programs/compressed-token/program/src/ctoken/create.rs | 5 +++-- programs/compressed-token/program/src/lib.rs | 6 +++--- .../program/src/shared/initialize_ctoken_account.rs | 3 +-- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index d74e90eb77..6132f0cdb3 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -580,6 +580,8 @@ pub enum ErrorCode { AuthorityTypeNotSupported, // 6165 (SPL Token code 15) #[msg("Mint decimals mismatch between the client and mint")] MintDecimalsMismatch, // 6166 (SPL Token code 18) + #[msg("Failed to calculate rent exemption for CMint")] + CMintRentExemptionFailed, // 6167 } /// Anchor error code offset - error codes start at 6000 diff --git a/programs/compressed-token/program/docs/ctoken/MINT_TO.md b/programs/compressed-token/program/docs/ctoken/MINT_TO.md index e7767a26d3..20f7e23363 100644 --- a/programs/compressed-token/program/docs/ctoken/MINT_TO.md +++ b/programs/compressed-token/program/docs/ctoken/MINT_TO.md @@ -13,7 +13,7 @@ Account layouts: - `CompressionInfo` extension defined in: program-libs/compressible/src/compression_info.rs **Instruction data:** -Path: programs/compressed-token/program/src/ctoken/mint_to.rs (lines 10-47) +Path: programs/compressed-token/program/src/ctoken/mint_to.rs (see `process_ctoken_mint_to` function) Byte layout: - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to mint diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 9831c3bc3c..412defe297 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -140,8 +140,8 @@ fn serialize_decompressed_mint( .ok_or(ErrorCode::CMintNotFound)?; let num_bytes = cmint_account.data_len() as u64; let current_lamports = cmint_account.lamports(); - let rent_exemption = get_rent_exemption_lamports(num_bytes) - .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; + let rent_exemption = + get_rent_exemption_lamports(num_bytes).map_err(|_| ErrorCode::CMintRentExemptionFailed)?; // Skip top-up calculation if decompress mint action is present // (rent was just paid during account creation). diff --git a/programs/compressed-token/program/src/ctoken/create.rs b/programs/compressed-token/program/src/ctoken/create.rs index 6bd6a3d473..32ff326756 100644 --- a/programs/compressed-token/program/src/ctoken/create.rs +++ b/programs/compressed-token/program/src/ctoken/create.rs @@ -90,8 +90,9 @@ pub fn process_create_token_account( false, )?) } else { - // Non-compressible account: token_account must already exist and be owned by our program - // This is SPL-compatible initialize_account3 behavior + // Non-compressible account: token_account must already exist and be owned by CToken program. + // Unlike SPL initialize_account3 (which expects System-owned), this expects a pre-existing + // CToken-owned account. Ownership is implicitly validated when writing to the account. None }; diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index 232e0b9a6c..a04e6d92dd 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -25,7 +25,9 @@ use ctoken::{ }; use crate::{ - compressed_token::mint_action::processor::process_mint_action, + compressed_token::{ + mint_action::processor::process_mint_action, transfer2::processor::process_transfer2, + }, convert_account_infos::convert_account_infos, }; @@ -126,8 +128,6 @@ impl From for InstructionType { #[cfg(not(feature = "cpi"))] use pinocchio::program_entrypoint; -use crate::compressed_token::transfer2::processor::process_transfer2; - #[cfg(not(feature = "cpi"))] program_entrypoint!(process_instruction); 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 790d4996b4..c1b2fd01de 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -301,7 +301,6 @@ fn configure_compression_info( // Only try to read decimals if mint has data (is initialized) if !mint_data.is_empty() { let owner = mint_account.owner(); - let decimals = mint_data.get(44); if !is_valid_mint(owner, &mint_data)? { msg!("Invalid mint account: not a valid mint"); @@ -310,7 +309,7 @@ fn configure_compression_info( // Mint layout: decimals at byte 44 for all token programs // (mint_authority option: 36, supply: 8) = 44 - compressible_ext.set_decimals(decimals.copied()); + compressible_ext.set_decimals(mint_data.get(44).copied()); } Ok(()) From 13d992b433709a669518bcda06ab81eed78a496b Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 01:51:15 +0000 Subject: [PATCH 16/38] fix js --- js/compressed-token/src/v3/layout/layout-mint-action.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/js/compressed-token/src/v3/layout/layout-mint-action.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts index ed75d7ea6c..5c169ce306 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -173,7 +173,6 @@ const ActionLayoutV1 = rustEnum([ MintToCompressedActionLayout.replicate('mintToCompressed'), UpdateAuthorityLayout.replicate('updateMintAuthority'), UpdateAuthorityLayout.replicate('updateFreezeAuthority'), - CreateSplMintActionLayout.replicate('createSplMint'), MintToCTokenActionLayout.replicate('mintToCToken'), UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), From 8c2e9f16ef8fa7ea4cf0efdd86a47b7f2d2057f1 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 15:30:38 +0000 Subject: [PATCH 17/38] fix js tests --- js/compressed-token/tests/e2e/unwrap.test.ts | 2 + .../tests/unit/layout-mint-action.test.ts | 56 ------------------- 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/js/compressed-token/tests/e2e/unwrap.test.ts b/js/compressed-token/tests/e2e/unwrap.test.ts index 7819948e5f..97ad4d811d 100644 --- a/js/compressed-token/tests/e2e/unwrap.test.ts +++ b/js/compressed-token/tests/e2e/unwrap.test.ts @@ -94,6 +94,7 @@ describe('createUnwrapInstruction', () => { mint, BigInt(1000), tokenPoolInfo!, + TEST_TOKEN_DECIMALS, ); expect(ix).toBeDefined(); @@ -125,6 +126,7 @@ describe('createUnwrapInstruction', () => { mint, BigInt(500), tokenPoolInfo!, + TEST_TOKEN_DECIMALS, feePayer.publicKey, ); diff --git a/js/compressed-token/tests/unit/layout-mint-action.test.ts b/js/compressed-token/tests/unit/layout-mint-action.test.ts index ff323f41da..e01c180e1e 100644 --- a/js/compressed-token/tests/unit/layout-mint-action.test.ts +++ b/js/compressed-token/tests/unit/layout-mint-action.test.ts @@ -203,62 +203,6 @@ describe('layout-mint-action', () => { expect('updateMintAuthority' in decoded.actions[0]).toBe(true); }); - it('should encode and decode with createSplMint action', () => { - const mint = Keypair.generate().publicKey; - - const createSplMintAction: Action = { - createSplMint: { - mintBump: 254, - }, - }; - - const data: MintActionCompressedInstructionData = { - leafIndex: 0, - proveByIndex: false, - rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 255, - tokenPoolIndex: 0, - maxTopUp: 0, - createMint: { - readOnlyAddressTrees: [1, 2, 3, 4], - readOnlyAddressTreeRootIndices: [10, 20, 30, 40], - }, - actions: [createSplMintAction], - proof: { - a: Array(32).fill(1), - b: Array(64).fill(2), - c: Array(32).fill(3), - }, - cpiContext: null, - mint: { - supply: 0n, - decimals: 9, - metadata: { - version: 1, - cmintDecompressed: false, - mint, - }, - mintAuthority: mint, - freezeAuthority: null, - extensions: null, - }, - }; - - const encoded = encodeMintActionInstructionData(data); - const decoded = decodeMintActionInstructionData(encoded); - - expect(decoded.actions.length).toBe(1); - expect('createSplMint' in decoded.actions[0]).toBe(true); - - const action = decoded.actions[0] as { - createSplMint: { mintBump: number }; - }; - expect(action.createSplMint.mintBump).toBe(254); - expect(decoded.createMint).not.toBe(null); - expect(decoded.proof).not.toBe(null); - }); - it('should encode and decode with multiple actions', () => { const mint = Keypair.generate().publicKey; const recipient = Keypair.generate().publicKey; From 4f2c674ff258fcaf8973f09ddc4ede5a1f7d04f2 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 16:19:49 +0000 Subject: [PATCH 18/38] fix: allow exactly MAX_COMPRESSIONS items in transfer2 Change validation from >= to > to fix off-by-one error that prevented using exactly MAX_COMPRESSIONS compression operations. --- .../program/src/compressed_token/transfer2/compression/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index cb4458e47f..c80a39df17 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -50,7 +50,7 @@ pub fn process_token_compression<'a>( compression_to_input: &[Option; MAX_COMPRESSIONS], ) -> Result<(), ProgramError> { if let Some(compressions) = inputs.compressions.as_ref() { - if compressions.len() >= MAX_COMPRESSIONS { + if compressions.len() > MAX_COMPRESSIONS { msg!( "Too many compressions: {} provided, maximum {} allowed", compressions.len(), From c74b901e2dffe354e9e57afb48c84fb62194813f Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 7 Jan 2026 23:24:17 +0000 Subject: [PATCH 19/38] fix: compressed token mint action and tests --- js/stateless.js/src/devnet-compat.ts | 1 - .../src/instructions/mint_action/builder.rs | 30 +- .../mint_action/instruction_data.rs | 12 +- .../src/state/mint/compressed_mint.rs | 13 +- .../src/state/mint/zero_copy.rs | 3 +- .../ctoken-interface/tests/compressed_mint.rs | 14 +- .../tests/cross_deserialization.rs | 3 +- .../tests/mint_borsh_zero_copy.rs | 20 +- .../tests/mint/cpi_context.rs | 293 +++++++++++++++++- .../tests/mint/functional.rs | 6 +- program-tests/utils/src/mint_assert.rs | 3 +- .../docs/compressed_token/MINT_ACTION.md | 4 + .../compressed_token/mint_action/accounts.rs | 20 +- .../mint_action/actions/create_mint.rs | 10 +- .../mint_action/actions/decompress_mint.rs | 22 +- .../mint_action/mint_input.rs | 25 +- .../mint_action/mint_output.rs | 13 +- .../compressed_token/mint_action/processor.rs | 25 +- .../mint_action/zero_copy_config.rs | 7 +- .../compressed-token/program/tests/mint.rs | 12 +- .../program/tests/mint_action.rs | 4 +- .../v2/create_compressed_mint/instruction.rs | 7 +- .../ctoken-sdk/src/ctoken/create_cmint.rs | 4 +- .../src/instructions/mint_action.rs | 2 +- sdk-tests/csdk-anchor-derived-test/src/lib.rs | 1 - .../tests/basic_test.rs | 1 + .../csdk-anchor-full-derived-test/src/lib.rs | 1 - .../tests/basic_test.rs | 1 + .../create_user_record_and_game_session.rs | 1 - .../tests/multi_account_tests.rs | 1 + .../sdk-token-test/src/ctoken_pda/mint.rs | 1 - .../sdk-token-test/src/pda_ctoken/mint.rs | 1 - sdk-tests/sdk-token-test/tests/ctoken_pda.rs | 1 + sdk-tests/sdk-token-test/tests/pda_ctoken.rs | 1 + .../sdk-token-test/tests/test_4_transfer2.rs | 3 +- .../tests/test_compress_full_and_close.rs | 3 +- 36 files changed, 420 insertions(+), 149 deletions(-) diff --git a/js/stateless.js/src/devnet-compat.ts b/js/stateless.js/src/devnet-compat.ts index 18c8a73c99..c00695c338 100644 --- a/js/stateless.js/src/devnet-compat.ts +++ b/js/stateless.js/src/devnet-compat.ts @@ -21,4 +21,3 @@ export function setDevnetCompat(enabled: boolean): void { export function isDevnetCompat(): boolean { return _useDevnetFormat; } - diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs b/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs index 31c9202468..8ca9fb9ff7 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/builder.rs @@ -22,6 +22,7 @@ impl InstructionDiscriminator for MintActionCompressedInstructionData { impl LightInstructionData for MintActionCompressedInstructionData {} impl MintActionCompressedInstructionData { + /// Create instruction data from CompressedMintWithContext (for existing mints) pub fn new( mint_with_context: CompressedMintWithContext, proof: Option, @@ -30,9 +31,6 @@ impl MintActionCompressedInstructionData { leaf_index: mint_with_context.leaf_index, prove_by_index: mint_with_context.prove_by_index, root_index: mint_with_context.root_index, - 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(), @@ -42,19 +40,16 @@ impl MintActionCompressedInstructionData { } } + /// Create instruction data for new mint creation pub fn new_mint( - compressed_address: [u8; 32], - root_index: u16, + address_merkle_tree_root_index: u16, proof: CompressedProof, mint: CompressedMintInstructionData, ) -> Self { Self { - leaf_index: 0, - prove_by_index: false, - root_index, - compressed_address, - token_pool_bump: 0, - token_pool_index: 0, + leaf_index: 0, // New mint has no existing leaf + prove_by_index: false, // Using address proof, not validity proof + root_index: address_merkle_tree_root_index, max_top_up: 0, // No limit by default create_mint: Some(CreateMint::default()), actions: Vec::new(), @@ -64,19 +59,16 @@ impl MintActionCompressedInstructionData { } } + /// Create instruction data for new mint creation via CPI context write pub fn new_mint_write_to_cpi_context( - compressed_address: [u8; 32], - root_index: u16, + address_merkle_tree_root_index: u16, mint: CompressedMintInstructionData, cpi_context: CpiContext, ) -> Self { Self { - leaf_index: 0, - prove_by_index: false, - root_index, - compressed_address, - token_pool_bump: 0, - token_pool_index: 0, + leaf_index: 0, // New mint has no existing leaf + prove_by_index: false, // Using address proof, not validity proof + root_index: address_merkle_tree_root_index, max_top_up: 0, // No limit by default create_mint: Some(CreateMint::default()), actions: Vec::new(), diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs index 890b8c4fd4..79bc78ff9a 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/instruction_data.rs @@ -50,15 +50,6 @@ pub struct MintActionCompressedInstructionData { /// If mint already exists, root index of validity proof /// If proof by index not used. pub root_index: u16, - /// Address of the compressed account the mint is stored in. - /// Derived from the associated spl mint pubkey. - pub compressed_address: [u8; 32], - /// Used to check token pool derivation. - /// Only required if associated spl mint exists and actions contain mint actions. - pub token_pool_bump: u8, - /// 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, @@ -209,8 +200,9 @@ impl<'a> TryFrom<&ZCompressedMintInstructionData<'a>> for CompressedMint { version: instruction_data.metadata.version, cmint_decompressed: instruction_data.metadata.cmint_decompressed != 0, mint: instruction_data.metadata.mint, + compressed_address: instruction_data.metadata.compressed_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: crate::state::mint::ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions, diff --git a/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs b/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs index 63f41d729b..d11e6e4307 100644 --- a/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs +++ b/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs @@ -17,7 +17,7 @@ pub struct CompressedMint { pub base: BaseMint, pub metadata: CompressedMintMetadata, /// Reserved bytes for T22 layout compatibility (padding to reach byte 165) - pub reserved: [u8; 49], + pub reserved: [u8; 17], /// Account type discriminator at byte 165 (1 = Mint, 2 = Account) pub account_type: u8, /// Compression info embedded directly in the mint @@ -30,7 +30,7 @@ impl Default for CompressedMint { Self { base: BaseMint::default(), metadata: CompressedMintMetadata::default(), - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -71,6 +71,9 @@ pub struct CompressedMintMetadata { pub cmint_decompressed: bool, /// Pda with seed address of compressed mint pub mint: Pubkey, + /// Address of the compressed account the mint is stored in. + /// Derived from the associated spl mint pubkey. + pub compressed_address: [u8; 32], } impl CompressedMint { @@ -119,6 +122,12 @@ impl CompressedMint { return Err(CTokenError::CMintNotInitialized); } + if !mint.is_cmint_account() { + #[cfg(feature = "solana")] + msg!("CMint account is not a CMint account"); + return Err(CTokenError::MintMismatch); + } + Ok(mint) } diff --git a/program-libs/ctoken-interface/src/state/mint/zero_copy.rs b/program-libs/ctoken-interface/src/state/mint/zero_copy.rs index e2f3ff6919..65c3b6e838 100644 --- a/program-libs/ctoken-interface/src/state/mint/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/mint/zero_copy.rs @@ -45,7 +45,7 @@ struct CompressedMintZeroCopyMeta { // CompressedMintMetadata pub metadata: CompressedMintMetadata, /// Reserved bytes for T22 layout compatibility (padding to reach byte 165) - pub reserved: [u8; 49], + pub reserved: [u8; 17], /// Account type discriminator at byte 165 (1 = Mint, 2 = Account) pub account_type: u8, /// Compression info embedded directly in the mint @@ -422,6 +422,7 @@ impl ZCompressedMintMut<'_> { self.base.metadata.version = ix_data.metadata.version; self.base.metadata.mint = ix_data.metadata.mint; self.base.metadata.cmint_decompressed = if cmint_decompressed { 1 } else { 0 }; + self.base.metadata.compressed_address = ix_data.metadata.compressed_address; // Set base fields self.base.supply = ix_data.supply; diff --git a/program-libs/ctoken-interface/tests/compressed_mint.rs b/program-libs/ctoken-interface/tests/compressed_mint.rs index 9f520e1201..9830c5c533 100644 --- a/program-libs/ctoken-interface/tests/compressed_mint.rs +++ b/program-libs/ctoken-interface/tests/compressed_mint.rs @@ -77,8 +77,9 @@ fn generate_random_compressed_mint(rng: &mut impl Rng, with_extensions: bool) -> version: 3, mint, cmint_decompressed: rng.gen_bool(0.5), + compressed_address: rng.gen(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions, @@ -146,6 +147,7 @@ fn test_compressed_mint_borsh_zerocopy_compatibility() { } else { 0 }; + zc_mint.base.metadata.compressed_address = original_mint.metadata.compressed_address; // account_type is already set in new_zero_copy // Set compression fields zc_mint.base.compression.config_account_version = @@ -257,8 +259,9 @@ fn test_compressed_mint_edge_cases() { version: 3, mint: Pubkey::from([0xff; 32]), cmint_decompressed: false, + compressed_address: [0u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -290,6 +293,7 @@ fn test_compressed_mint_edge_cases() { zc_mint.base.metadata.version = mint_no_auth.metadata.version; zc_mint.base.metadata.mint = mint_no_auth.metadata.mint; zc_mint.base.metadata.cmint_decompressed = 0; + zc_mint.base.metadata.compressed_address = mint_no_auth.metadata.compressed_address; // account_type is already set in new_zero_copy // Set compression fields zc_mint.base.compression.config_account_version = @@ -334,8 +338,9 @@ fn test_compressed_mint_edge_cases() { version: 255, mint: Pubkey::from([0xbb; 32]), cmint_decompressed: true, + compressed_address: [0xcc; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, @@ -361,8 +366,9 @@ fn test_base_mint_in_compressed_mint_spl_format() { version: 3, mint: Pubkey::from([3; 32]), cmint_decompressed: false, + compressed_address: [4u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, diff --git a/program-libs/ctoken-interface/tests/cross_deserialization.rs b/program-libs/ctoken-interface/tests/cross_deserialization.rs index e1d90d7854..1602621156 100644 --- a/program-libs/ctoken-interface/tests/cross_deserialization.rs +++ b/program-libs/ctoken-interface/tests/cross_deserialization.rs @@ -29,8 +29,9 @@ fn create_test_cmint() -> CompressedMint { version: 3, mint: Pubkey::new_from_array([2; 32]), cmint_decompressed: false, + compressed_address: [5u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo { config_account_version: 1, diff --git a/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs b/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs index 02b7a8331f..34feaed87f 100644 --- a/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs +++ b/program-libs/ctoken-interface/tests/mint_borsh_zero_copy.rs @@ -103,8 +103,9 @@ fn generate_random_mint() -> CompressedMint { rng.fill(&mut bytes); Pubkey::from(bytes) }, + compressed_address: rng.gen(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions, @@ -165,6 +166,7 @@ fn compare_mint_borsh_vs_zero_copy(original: &CompressedMint, borsh_bytes: &[u8] version: zc_mint.base.metadata.version, cmint_decompressed: zc_mint.base.metadata.cmint_decompressed != 0, mint: zc_mint.base.metadata.mint, + compressed_address: zc_mint.base.metadata.compressed_address, }, reserved: *zc_mint.base.reserved, account_type: zc_mint.base.account_type, @@ -189,6 +191,7 @@ fn compare_mint_borsh_vs_zero_copy(original: &CompressedMint, borsh_bytes: &[u8] version: zc_mint_mut.base.metadata.version, cmint_decompressed: zc_mint_mut.base.metadata.cmint_decompressed != 0, mint: zc_mint_mut.base.metadata.mint, + compressed_address: zc_mint_mut.base.metadata.compressed_address, }, reserved: *zc_mint_mut.base.reserved, account_type: *zc_mint_mut.base.account_type, @@ -252,8 +255,9 @@ fn generate_mint_with_extensions() -> CompressedMint { version: 3, cmint_decompressed: rng.gen_bool(0.5), mint: Pubkey::from(rng.gen::<[u8; 32]>()), + compressed_address: rng.gen(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(token_metadata)]), @@ -286,8 +290,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: false, mint: Pubkey::from([2u8; 32]), + compressed_address: [0u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(TokenMetadata { @@ -315,8 +320,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: true, mint: Pubkey::from([0xbbu8; 32]), + compressed_address: [0xddu8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(TokenMetadata { @@ -357,8 +363,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: false, mint: Pubkey::from([4u8; 32]), + compressed_address: [5u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ExtensionStruct::TokenMetadata(TokenMetadata { @@ -386,8 +393,9 @@ fn test_mint_extension_edge_cases() { version: 3, cmint_decompressed: true, mint: Pubkey::from([7u8; 32]), + compressed_address: [8u8; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, diff --git a/program-tests/compressed-token-test/tests/mint/cpi_context.rs b/program-tests/compressed-token-test/tests/mint/cpi_context.rs index b3a1249206..90c2e4eebb 100644 --- a/program-tests/compressed-token-test/tests/mint/cpi_context.rs +++ b/program-tests/compressed-token-test/tests/mint/cpi_context.rs @@ -4,8 +4,8 @@ use light_client::indexer::Indexer; use light_compressed_account::instruction_data::traits::LightInstructionData; use light_ctoken_interface::{ instructions::mint_action::{ - CompressedMintInstructionData, CompressedMintWithContext, CpiContext, - MintActionCompressedInstructionData, + CompressedMintInstructionData, CompressedMintWithContext, CpiContext, DecompressMintAction, + MintActionCompressedInstructionData, MintToCTokenAction, }, state::CompressedMintMetadata, CMINT_ADDRESS_TREE, CTOKEN_PROGRAM_ID, @@ -83,6 +83,7 @@ async fn test_setup() -> TestSetup { version: 3, cmint_decompressed: false, mint: spl_mint_pda.into(), + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: Some(freeze_authority.into()), @@ -124,7 +125,6 @@ async fn test_write_to_cpi_context_create_mint() { // Build instruction data using new builder API let instruction_data = MintActionCompressedInstructionData::new_mint( - compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), compressed_mint_inputs.mint.clone().unwrap(), @@ -245,7 +245,6 @@ async fn test_write_to_cpi_context_invalid_address_tree() { // Build instruction data with invalid address tree let instruction_data = MintActionCompressedInstructionData::new_mint( - compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), compressed_mint_inputs.mint.clone().unwrap(), @@ -335,12 +334,14 @@ async fn test_write_to_cpi_context_invalid_compressed_address() { // Keep the correct address_tree_pubkey but provide wrong address let invalid_compressed_address = [42u8; 32]; - // Build instruction data with invalid compressed address + // Build instruction data with invalid compressed address in metadata + let mut invalid_mint = compressed_mint_inputs.mint.clone().unwrap(); + invalid_mint.metadata.compressed_address = invalid_compressed_address; + let instruction_data = MintActionCompressedInstructionData::new_mint( - invalid_compressed_address, compressed_mint_inputs.root_index, CompressedProof::default(), - compressed_mint_inputs.mint.clone().unwrap(), + invalid_mint, ) .with_cpi_context(CpiContext { set_context: false, @@ -438,7 +439,6 @@ async fn test_execute_cpi_context_invalid_tree_index() { // Build instruction data for execute mode - must mark as create_mint let instruction_data = MintActionCompressedInstructionData::new_mint( - compressed_mint_inputs.address, compressed_mint_inputs.root_index, CompressedProof::default(), compressed_mint_inputs.mint.clone().unwrap(), @@ -500,3 +500,280 @@ async fn test_execute_cpi_context_invalid_tree_index() { // Error code 6104 = MintActionInvalidCpiContextForCreateMint assert_rpc_error(result, 0, 6104).unwrap(); } + +#[tokio::test] +#[serial] +async fn test_write_to_cpi_context_decompressed_mint_fails() { + let TestSetup { + mut rpc, + compressed_mint_inputs: _, + payer, + mint_seed: _, + mint_authority, + compressed_mint_address, + cpi_context_pubkey, + address_tree, + address_tree_index, + output_queue: _, + output_queue_index, + } = test_setup().await; + + // Build instruction data with mint = None (simulates decompressed mint) + // This triggers cmint_decompressed = true in AccountsConfig + let mint_with_context = CompressedMintWithContext { + leaf_index: 0, + prove_by_index: false, + root_index: 0, + address: compressed_mint_address, + mint: None, + }; + + let instruction_data = MintActionCompressedInstructionData::new(mint_with_context, None) + .with_cpi_context(CpiContext { + set_context: false, + first_set_context: true, + in_tree_index: address_tree_index, + in_queue_index: 0, + out_queue_index: output_queue_index, + token_out_queue_index: 0, + assigned_account_index: 0, + read_only_address_trees: [0; 4], + address_tree_pubkey: address_tree.to_bytes(), + }); + + // Build account metas for CPI write mode + let config = MintActionMetaConfigCpiWrite { + fee_payer: payer.pubkey(), + mint_signer: None, + authority: mint_authority.pubkey(), + cpi_context: cpi_context_pubkey, + }; + + let account_metas = get_mint_action_instruction_account_metas_cpi_write(config); + + // Serialize instruction data + let data = instruction_data + .data() + .expect("Failed to serialize instruction data"); + + // Build compressed token instruction + let ctoken_instruction = Instruction { + program_id: Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + accounts: account_metas, + data: data.clone(), + }; + + // Build wrapper instruction + let wrapper_ix_data = + compressed_token_test::instruction::WriteToCpiContextMintAction { inputs: data }; + + let wrapper_instruction = Instruction { + program_id: WRAPPER_PROGRAM_ID, + accounts: vec![AccountMeta::new_readonly( + Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + false, + )] + .into_iter() + .chain(ctoken_instruction.accounts.clone()) + .collect(), + data: wrapper_ix_data.data(), + }; + + // Execute wrapper instruction - should fail with CpiContextSetNotUsable + let result = rpc + .create_and_send_transaction( + &[wrapper_instruction], + &payer.pubkey(), + &[&payer, &mint_authority], + ) + .await; + + // Assert error code 6035 = CpiContextSetNotUsable + // "Decompress mint not allowed when writing to cpi context" + assert_rpc_error(result, 0, 6035).unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_write_to_cpi_context_mint_to_ctoken_fails() { + let TestSetup { + mut rpc, + compressed_mint_inputs, + payer, + mint_seed, + mint_authority, + compressed_mint_address: _, + cpi_context_pubkey, + address_tree, + address_tree_index, + output_queue: _, + output_queue_index, + } = test_setup().await; + + // Build instruction data for create mint with MintToCToken action + // MintToCToken is not allowed when writing to CPI context + let instruction_data = MintActionCompressedInstructionData::new_mint( + compressed_mint_inputs.root_index, + CompressedProof::default(), + compressed_mint_inputs.mint.clone().unwrap(), + ) + .with_mint_to_ctoken(MintToCTokenAction { + account_index: 0, + amount: 1000, + }) + .with_cpi_context(CpiContext { + set_context: false, + first_set_context: true, + in_tree_index: address_tree_index, + in_queue_index: 0, + out_queue_index: output_queue_index, + token_out_queue_index: 0, + assigned_account_index: 0, + read_only_address_trees: [0; 4], + address_tree_pubkey: address_tree.to_bytes(), + }); + + // Build account metas for CPI write mode + let config = MintActionMetaConfigCpiWrite { + fee_payer: payer.pubkey(), + mint_signer: Some(mint_seed.pubkey()), + authority: mint_authority.pubkey(), + cpi_context: cpi_context_pubkey, + }; + + let account_metas = get_mint_action_instruction_account_metas_cpi_write(config); + + // Serialize instruction data + let data = instruction_data + .data() + .expect("Failed to serialize instruction data"); + + // Build compressed token instruction + let ctoken_instruction = Instruction { + program_id: Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + accounts: account_metas, + data: data.clone(), + }; + + // Build wrapper instruction + let wrapper_ix_data = + compressed_token_test::instruction::WriteToCpiContextMintAction { inputs: data }; + + let wrapper_instruction = Instruction { + program_id: WRAPPER_PROGRAM_ID, + accounts: vec![AccountMeta::new_readonly( + Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + false, + )] + .into_iter() + .chain(ctoken_instruction.accounts.clone()) + .collect(), + data: wrapper_ix_data.data(), + }; + + // Execute wrapper instruction - should fail with CpiContextSetNotUsable + let result = rpc + .create_and_send_transaction( + &[wrapper_instruction], + &payer.pubkey(), + &[&payer, &mint_seed, &mint_authority], + ) + .await; + + // Assert error code 6035 = CpiContextSetNotUsable + // "Mint to ctokens not allowed when writing to cpi context" + assert_rpc_error(result, 0, 6035).unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_write_to_cpi_context_decompress_mint_action_fails() { + let TestSetup { + mut rpc, + compressed_mint_inputs, + payer, + mint_seed, + mint_authority, + compressed_mint_address: _, + cpi_context_pubkey, + address_tree, + address_tree_index, + output_queue: _, + output_queue_index, + } = test_setup().await; + + // Build instruction data for create mint with DecompressMint action + // DecompressMint is not allowed when writing to CPI context + let instruction_data = MintActionCompressedInstructionData::new_mint( + compressed_mint_inputs.root_index, + CompressedProof::default(), + compressed_mint_inputs.mint.clone().unwrap(), + ) + .with_decompress_mint(DecompressMintAction { + cmint_bump: 255, + rent_payment: 2, + write_top_up: 1000, + }) + .with_cpi_context(CpiContext { + set_context: false, + first_set_context: true, + in_tree_index: address_tree_index, + in_queue_index: 0, + out_queue_index: output_queue_index, + token_out_queue_index: 0, + assigned_account_index: 0, + read_only_address_trees: [0; 4], + address_tree_pubkey: address_tree.to_bytes(), + }); + + // Build account metas for CPI write mode + let config = MintActionMetaConfigCpiWrite { + fee_payer: payer.pubkey(), + mint_signer: Some(mint_seed.pubkey()), + authority: mint_authority.pubkey(), + cpi_context: cpi_context_pubkey, + }; + + let account_metas = get_mint_action_instruction_account_metas_cpi_write(config); + + // Serialize instruction data + let data = instruction_data + .data() + .expect("Failed to serialize instruction data"); + + // Build compressed token instruction + let ctoken_instruction = Instruction { + program_id: Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + accounts: account_metas, + data: data.clone(), + }; + + // Build wrapper instruction + let wrapper_ix_data = + compressed_token_test::instruction::WriteToCpiContextMintAction { inputs: data }; + + let wrapper_instruction = Instruction { + program_id: WRAPPER_PROGRAM_ID, + accounts: vec![AccountMeta::new_readonly( + Pubkey::new_from_array(CTOKEN_PROGRAM_ID), + false, + )] + .into_iter() + .chain(ctoken_instruction.accounts.clone()) + .collect(), + data: wrapper_ix_data.data(), + }; + + // Execute wrapper instruction - should fail with CpiContextSetNotUsable + let result = rpc + .create_and_send_transaction( + &[wrapper_instruction], + &payer.pubkey(), + &[&payer, &mint_seed, &mint_authority], + ) + .await; + + // Assert error code 6035 = CpiContextSetNotUsable + // "Decompress mint not allowed when writing to cpi context" + assert_rpc_error(result, 0, 6035).unwrap(); +} diff --git a/program-tests/compressed-token-test/tests/mint/functional.rs b/program-tests/compressed-token-test/tests/mint/functional.rs index 37211d0bdd..9801e5e5f0 100644 --- a/program-tests/compressed-token-test/tests/mint/functional.rs +++ b/program-tests/compressed-token-test/tests/mint/functional.rs @@ -1228,8 +1228,9 @@ async fn test_mint_actions() { version: 3, // With metadata mint: spl_mint_pda.into(), cmint_decompressed: false, // Becomes true after DecompressMint action + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: Some(vec![ @@ -1461,8 +1462,9 @@ async fn test_create_compressed_mint_with_cmint() { version: 3, cmint_decompressed: false, // Before DecompressMint mint: cmint_pda.to_bytes().into(), + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: CompressionInfo::default(), extensions: None, diff --git a/program-tests/utils/src/mint_assert.rs b/program-tests/utils/src/mint_assert.rs index bd50457e7f..b73287f159 100644 --- a/program-tests/utils/src/mint_assert.rs +++ b/program-tests/utils/src/mint_assert.rs @@ -46,8 +46,9 @@ pub fn assert_compressed_mint_account( version: 3, mint: spl_mint_pda.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: light_compressible::compression_info::CompressionInfo::default(), extensions: expected_extensions, diff --git a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index eb44dcae7c..f123acacd4 100644 --- a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -221,3 +221,7 @@ Packed accounts (remaining accounts): - `ErrorCode::CpiContextExpected` (error code: 6085) - CPI context required but not provided - `AccountError::InvalidSigner` (error code: 12015) - Required signer account is not signing - `AccountError::NotEnoughAccountKeys` (error code: 12020) - Missing required accounts + +### Spl mint migration +- cmint to spl mint migration is unimplemented and not planned. +- A way to support it in the future would require a new instruction that creates an spl mint in the mint pda solana account and mints the supply to the spl interface. diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index b4727d6c15..b504bb7ac2 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -18,8 +18,8 @@ use crate::shared::{ pub struct MintActionAccounts<'info> { pub light_system_program: &'info AccountInfo, - /// Seed for spl mint pda. - /// Required for mint and spl mint creation. + /// Seed for mint PDA derivation. + /// Required for compressed mint creation and DecompressMint. /// Note: mint_signer is not in executing accounts since create mint /// is allowed in combination with write to cpi context. pub mint_signer: Option<&'info AccountInfo>, @@ -45,7 +45,7 @@ pub struct ExecutingAccounts<'info> { /// CompressibleConfig - parsed and validated (active state) when creating CMint. pub compressible_config: Option<&'info CompressibleConfig>, /// CMint Solana account (decompressed compressed mint). - /// Required for DecompressMint action and when syncing with existing CMint. + /// Required for DecompressMint, CompressAndCloseCMint, and operations on decompressed mints. pub cmint: Option<&'info AccountInfo>, /// Rent sponsor PDA - required when creating CMint (pays for account). pub rent_sponsor: Option<&'info AccountInfo>, @@ -107,7 +107,7 @@ impl<'info> MintActionAccounts<'info> { None }; - // CMint account required if already decompressed (for sync) OR being decompressed/closed + // CMint account required if already decompressed OR being decompressed/closed let cmint = iter.next_option_mut("cmint", config.needs_cmint_account())?; // Parse rent_sponsor when creating or closing CMint @@ -345,11 +345,11 @@ pub struct AccountsConfig { /// 2. cpi context.first_set() || cpi context.set() pub write_to_cpi_context: bool, /// 4. Whether the compressed mint has been decompressed to a CMint Solana account. - /// When true, the CMint account is the source of truth and must be synced. + /// When true, the CMint account is the decompressed (compressed account is empty). pub cmint_decompressed: bool, /// 5. Mint pub has_mint_to_actions: bool, - /// 6. Either compressed mint and/or spl mint is created. + /// 6. Compressed mint is created or DecompressMint action is present. pub with_mint_signer: bool, /// 7. Compressed mint is created. pub create_mint: bool, @@ -360,11 +360,11 @@ pub struct AccountsConfig { } impl AccountsConfig { - /// Returns true when CMint Solana account is the source of truth for mint data. + /// Returns true when CMint Solana account is the decompressed for mint data. /// This is the case when the mint is decompressed (or being decompressed) and not being closed. /// When true, compressed account uses zero sentinel values (discriminator=[0;8], data_hash=[0;32]). #[inline(always)] - pub fn cmint_is_decompressed(&self) -> bool { + pub fn cmint_output_decompressed(&self) -> bool { (self.has_decompress_mint_action || self.cmint_decompressed) && !self.has_compress_and_close_cmint_action } @@ -441,8 +441,8 @@ impl AccountsConfig { // CompressAndCloseCMint does NOT need mint_signer - it verifies CMint by compressed_mint.metadata.mint let with_mint_signer = parsed_instruction_data.create_mint.is_some() || has_decompress_mint_action; - // CMint account needed for sync when mint is already decompressed (metadata flag) - // When mint is None, it means CMint is decompressed (data lives in CMint account) + // CMint account needed when mint is already decompressed (metadata flag) + // When mint is None, CMint is decompressed (data lives in CMint account, compressed account is empty) let cmint_decompressed = parsed_instruction_data.mint.is_none(); if write_to_cpi_context { diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs index 0aef09ed01..561ee709c3 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/create_mint.rs @@ -26,7 +26,7 @@ pub fn process_create_mint_action( // 1. Derive compressed mint address without bump to ensure // that only one mint per seed can be created. - let spl_mint_pda = solana_pubkey::Pubkey::find_program_address( + let mint_pda = solana_pubkey::Pubkey::find_program_address( &[COMPRESSED_MINT_SEED, mint_signer.as_slice()], &crate::ID, ) @@ -38,7 +38,7 @@ pub fn process_create_mint_action( .as_ref() .ok_or(ProgramError::InvalidInstructionData)?; - if !pubkey_eq(&spl_mint_pda, mint.metadata.mint.array_ref()) { + if !pubkey_eq(&mint_pda, mint.metadata.mint.array_ref()) { msg!("Invalid mint PDA derivation"); return Err(ErrorCode::MintActionInvalidMintPda.into()); } @@ -56,11 +56,11 @@ pub fn process_create_mint_action( return Err(ErrorCode::MintActionInvalidCpiContextAddressTreePubkey.into()); } let address = light_compressed_account::address::derive_address( - &spl_mint_pda, + &mint_pda, &cpi_context.address_tree_pubkey, &crate::LIGHT_CPI_SIGNER.program_id, ); - if address != parsed_instruction_data.compressed_address { + if address != mint.metadata.compressed_address { msg!("Invalid compressed mint address derivation"); return Err(ErrorCode::MintActionInvalidCompressedMintAddress.into()); } @@ -68,7 +68,7 @@ pub fn process_create_mint_action( // 2. Create NewAddressParams cpi_instruction_struct.new_address_params[0].set( - spl_mint_pda, + mint_pda, parsed_instruction_data.root_index, Some( parsed_instruction_data diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index 06c363aff0..79c366aac6 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -1,6 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; -use light_compressible::{compression_info::CompressionInfo, rent::RentConfig}; +use light_compressible::compression_info::CompressionInfo; use light_ctoken_interface::{ instructions::mint_action::ZDecompressMintAction, state::CompressedMint, COMPRESSED_MINT_SEED, }; @@ -32,16 +32,11 @@ use crate::{ /// 5. **Add Compressible Extension**: Add CompressionInfo to the compressed mint extensions /// 6. **PDA Verification**: Verify CMint account matches expected PDA derivation /// 7. **Account Creation**: rent_sponsor pays rent exemption, fee_payer pays Light rent -/// 8. **Flag Update**: Set cmint_decompressed flag (synced at end of MintAction) +/// 8. **Flag Update**: Set cmint_decompressed flag /// /// ## Note -/// DecompressMint is **permissionless** - anyone can call it (they pay for the CMint creation). +/// DecompressMint is **permissionless** - the caller pays initial rent, rent exemption is sponsored by the rent_sponsor. /// The authority signer is still required for MintAction, but does not need to match mint_authority. -/// -/// ## Note -/// The CMint account data is NOT serialized here. The sync logic at the end -/// of the MintAction processor will write the output compressed mint to the -/// CMint account. #[profile] pub fn process_decompress_mint_action( action: &ZDecompressMintAction, @@ -50,9 +45,6 @@ pub fn process_decompress_mint_action( mint_signer: &AccountInfo, fee_payer: &AccountInfo, ) -> Result<(), ProgramError> { - // NOTE: DecompressMint is permissionless - anyone can decompress (they pay for the account) - // No authority check required - // 1. Check not already decompressed if compressed_mint.metadata.cmint_decompressed { msg!("CMint account already exists"); @@ -113,13 +105,7 @@ pub fn process_decompress_mint_action( compression_authority: config.compression_authority.to_bytes(), rent_sponsor: config.rent_sponsor.to_bytes(), last_claimed_slot: current_slot, - rent_config: RentConfig { - base_rent: config.rent_config.base_rent, - compression_cost: config.rent_config.compression_cost, - lamports_per_byte_per_epoch: config.rent_config.lamports_per_byte_per_epoch, - max_funded_epochs: config.rent_config.max_funded_epochs, - max_top_up: config.rent_config.max_top_up, - }, + rent_config: config.rent_config, }; // 6. Verify PDA derivation diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs index 0f9f8a23a1..bf88821327 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_input.rs @@ -1,12 +1,11 @@ use anchor_lang::solana_program::program_error::ProgramError; use borsh::BorshSerialize; use light_compressed_account::instruction_data::with_readonly::ZInAccountMut; -use light_ctoken_interface::{ - instructions::mint_action::ZMintActionCompressedInstructionData, state::CompressedMint, -}; +use light_ctoken_interface::state::CompressedMint; use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; use light_sdk::instruction::PackedMerkleContext; +use light_zero_copy::U16; use crate::{ compressed_token::mint_action::accounts::AccountsConfig, @@ -18,29 +17,21 @@ use crate::{ /// but processes existing compressed mint accounts as inputs. /// /// Steps: -/// 1. Determine if CMint is source of truth (use zero values) or data from instruction +/// 1. Determine if CMint is decompressed (use zero values) or data from instruction /// 2. Set InAccount fields (discriminator, merkle hash, address) #[profile] pub fn create_input_compressed_mint_account( input_compressed_account: &mut ZInAccountMut, - mint_instruction_data: &ZMintActionCompressedInstructionData, + root_index: U16, merkle_context: PackedMerkleContext, accounts_config: &AccountsConfig, + compressed_mint: &CompressedMint, ) -> Result<(), ProgramError> { - // When CMint was source of truth (input state BEFORE actions), use zero sentinel values - // Use cmint_decompressed directly, not cmint_is_decompressed(), because: - // - cmint_is_decompressed() tells us the OUTPUT state (after actions) - // - cmint_decompressed tells us the INPUT state (before actions) - // For CompressAndCloseCMint: input has zero values (was decompressed), output has real data + // When CMint was decompressed (input state BEFORE actions), use zero values let (discriminator, input_data_hash) = if accounts_config.cmint_decompressed { ([0u8; 8], [0u8; 32]) } else { // Data from instruction - compute hash - let mint_data = mint_instruction_data - .mint - .as_ref() - .ok_or(ProgramError::InvalidInstructionData)?; - let compressed_mint = CompressedMint::try_from(mint_data)?; let bytes = compressed_mint .try_to_vec() .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; @@ -55,9 +46,9 @@ pub fn create_input_compressed_mint_account( discriminator, input_data_hash, &merkle_context, - mint_instruction_data.root_index, + root_index, 0, - Some(mint_instruction_data.compressed_address.as_ref()), + Some(compressed_mint.metadata.compressed_address.as_ref()), )?; Ok(()) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 412defe297..9122b91f03 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -22,7 +22,8 @@ use crate::{ shared::{convert_program_error, transfer_lamports::transfer_lamports}, }; -/// Processes the output compressed mint account, syncing to CMint if decompressed. +/// Processes the output compressed mint account. +/// When decompressed, writes mint data to CMint account (compressed account is empty). #[profile] pub fn process_output_compressed_account<'a>( parsed_instruction_data: &ZMintActionCompressedInstructionData, @@ -49,12 +50,7 @@ pub fn process_output_compressed_account<'a>( serialize_decompressed_mint(validated_accounts, accounts_config, &mut compressed_mint)?; } - serialize_compressed_mint( - mint_account, - compressed_mint, - parsed_instruction_data, - queue_indices, - ) + serialize_compressed_mint(mint_account, compressed_mint, queue_indices) } #[inline(always)] @@ -75,7 +71,6 @@ fn split_mint_and_token_accounts<'a>( fn serialize_compressed_mint<'a>( mint_account: &'a mut ZOutputCompressedAccountWithPackedContextMut<'a>, compressed_mint: CompressedMint, - parsed_instruction_data: &ZMintActionCompressedInstructionData, queue_indices: &QueueIndices, ) -> Result<(), ProgramError> { let compressed_account_data = mint_account @@ -122,7 +117,7 @@ fn serialize_compressed_mint<'a>( mint_account.set( crate::LIGHT_CPI_SIGNER.program_id.into(), 0, - Some(parsed_instruction_data.compressed_address), + Some(compressed_mint.metadata.compressed_address), queue_indices.output_queue_index, discriminator, data_hash, diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs index 5ba9cdf883..1139279963 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs @@ -44,19 +44,24 @@ pub fn process_mint_action( // Get mint data based on source: // 1. Creating new mint: mint data required in instruction // 2. Existing compressed mint: mint data in instruction (cmint_decompressed = false) - // 3. CMint is source of truth: read from CMint account (cmint_decompressed = true) - let mint = if parsed_instruction_data.create_mint.is_some() { + // 3. CMint is decompressed: read from CMint account (cmint_decompressed = true) + let mint = if accounts_config.create_mint { // Creating new mint - mint data required in instruction let mint_data = parsed_instruction_data .mint .as_ref() .ok_or(ErrorCode::MintDataRequired)?; CompressedMint::try_from(mint_data)? - } else if let Some(mint_data) = parsed_instruction_data.mint.as_ref() { + } else if !accounts_config.cmint_decompressed { // Existing compressed mint with data in instruction + // In case that cmint is not actually compressed proof verification will fail. + let mint_data = parsed_instruction_data + .mint + .as_ref() + .ok_or(ErrorCode::MintDataRequired)?; CompressedMint::try_from(mint_data)? } else { - // CMint is source of truth - read from CMint account + // CMint is decompressed - read from CMint account let cmint_account = validated_accounts .get_cmint() .ok_or(ErrorCode::MintActionMissingCMintAccount)?; @@ -90,7 +95,7 @@ pub fn process_mint_action( let queue_keys_match = validated_accounts.queue_keys_match(); let queue_indices = QueueIndices::new( parsed_instruction_data.cpi_context.as_ref(), - parsed_instruction_data.create_mint.is_some(), + accounts_config.create_mint, tokens_out_queue_exists, queue_keys_match, accounts_config.write_to_cpi_context, @@ -100,7 +105,7 @@ pub fn process_mint_action( // 1. Creating mint: mint data from instruction (must be Some) // 2. Existing mint with data in instruction: use instruction data // 3. Existing decompressed mint (CMint): read from CMint account - if parsed_instruction_data.create_mint.is_some() { + if accounts_config.create_mint { // Creating new mint - mint data required in instruction process_create_mint_action( &parsed_instruction_data, @@ -113,11 +118,12 @@ pub fn process_mint_action( queue_indices.address_merkle_tree_index, )?; } else { - // Decompressed mint (CMint is source of truth) - data from CMint account - // Set input with zero values (data lives in CMint) + // Existing mint - set input compressed account + // When CMint is decompressed, input has zero values + // When data from instruction, input has real data hash create_input_compressed_mint_account( &mut cpi_instruction_struct.input_compressed_accounts[0], - &parsed_instruction_data, + parsed_instruction_data.root_index, PackedMerkleContext { merkle_tree_pubkey_index: queue_indices.in_tree_index, queue_pubkey_index: queue_indices.in_queue_index, @@ -125,6 +131,7 @@ pub fn process_mint_action( prove_by_index: parsed_instruction_data.prove_by_index(), }, &accounts_config, + &mint, )?; }; diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs index 9ed12f3e53..24f453d035 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/zero_copy_config.rs @@ -85,8 +85,7 @@ pub fn get_zero_copy_configs( msg!("Max allowed is 29 compressed token recipients"); return Err(ErrorCode::TooManyMintToRecipients.into()); } - // CMint is source of truth when decompressed and not closing - let cmint_is_decompressed = accounts_config.cmint_is_decompressed(); + let cmint_is_decompressed = accounts_config.cmint_output_decompressed(); let input = CpiConfigInput { input_accounts: { @@ -100,7 +99,7 @@ pub fn get_zero_copy_configs( output_accounts: { let mut outputs = ArrayVec::new(); // First output is always the mint account - // When CMint is source of truth, use data_len=0 (zero discriminator/hash) + // When CMint is decompressed, use data_len=0 (zero discriminator & hash) let mint_data_len = if cmint_is_decompressed { 0 } else { @@ -111,7 +110,7 @@ pub fn get_zero_copy_configs( // Add token accounts for recipients for _ in 0..num_recipients { outputs.push((false, compressed_token_data_len(false))); - // No delegates for simple mint + // No delegates for mint to } outputs }, diff --git a/programs/compressed-token/program/tests/mint.rs b/programs/compressed-token/program/tests/mint.rs index 6cd0030a21..3c7f4e605b 100644 --- a/programs/compressed-token/program/tests/mint.rs +++ b/programs/compressed-token/program/tests/mint.rs @@ -119,6 +119,7 @@ fn test_rnd_create_compressed_mint_account() { version, mint: mint_pda, cmint_decompressed, + compressed_address: compressed_account_address, }, mint_authority: Some(mint_authority), freeze_authority, @@ -128,14 +129,10 @@ fn test_rnd_create_compressed_mint_account() { // Step 3: Create MintActionCompressedInstructionData let mint_action_data = MintActionCompressedInstructionData { create_mint: None, // We're testing with existing mint - leaf_index, prove_by_index, root_index, - compressed_address: compressed_account_address, mint: Some(mint_instruction_data.clone()), - token_pool_bump: 0, - token_pool_index: 0, actions: vec![], // No actions for basic test proof: None, cpi_context: None, @@ -176,9 +173,10 @@ fn test_rnd_create_compressed_mint_account() { create_input_compressed_mint_account( input_account, - &parsed_instruction_data, + root_index.into(), merkle_context, &accounts_config, + &cmint, ) .unwrap(); @@ -380,8 +378,9 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { version: 3u8, mint: Pubkey::new_from_array([3; 32]), cmint_decompressed: false, + compressed_address: [5; 32], }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, extensions: Some(vec![ExtensionStruct::TokenMetadata(token_metadata)]), }; @@ -432,6 +431,7 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { version: zc_mint.base.metadata.version, mint: zc_mint.base.metadata.mint, cmint_decompressed: zc_mint.base.metadata.cmint_decompressed != 0, + compressed_address: zc_mint.base.metadata.compressed_address, }, reserved: *zc_mint.base.reserved, account_type: zc_mint.base.account_type, diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index 0e548bea9a..a378118cd0 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -42,6 +42,7 @@ fn random_compressed_mint_metadata(rng: &mut StdRng) -> CompressedMintMetadata { version: rng.gen_range(1..=3) as u8, cmint_decompressed: rng.gen_bool(0.5), mint: random_pubkey(rng), + compressed_address: rng.gen::<[u8; 32]>(), } } @@ -175,9 +176,6 @@ fn generate_random_instruction_data( leaf_index: rng.gen::(), prove_by_index: rng.gen_bool(0.5), root_index: rng.gen::(), - 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) { diff --git a/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs b/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs index 521f1b311e..cc05d04ab0 100644 --- a/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs +++ b/sdk-libs/ctoken-sdk/src/compressed_token/v2/create_compressed_mint/instruction.rs @@ -52,6 +52,7 @@ pub fn create_compressed_mint_cpi( version: input.version, mint: find_cmint_address(&input.mint_signer).0.to_bytes().into(), cmint_decompressed: false, + compressed_address: mint_address, }, mint_authority: Some(input.mint_authority.to_bytes().into()), freeze_authority: input.freeze_authority.map(|auth| auth.to_bytes().into()), @@ -59,7 +60,6 @@ pub fn create_compressed_mint_cpi( }; let mut instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - mint_address, input.address_merkle_tree_root_index, input.proof, compressed_mint_instruction_data, @@ -134,6 +134,7 @@ pub fn create_compressed_mint_cpi_write( version: input.version, mint: find_cmint_address(&input.mint_signer).0.to_bytes().into(), cmint_decompressed: false, + compressed_address: input.mint_address, }, mint_authority: Some(input.mint_authority.to_bytes().into()), freeze_authority: input.freeze_authority.map(|auth| auth.to_bytes().into()), @@ -141,9 +142,9 @@ pub fn create_compressed_mint_cpi_write( }; let instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint_write_to_cpi_context( - input.mint_address, input.address_merkle_tree_root_index, - compressed_mint_instruction_data,input.cpi_context + compressed_mint_instruction_data, + input.cpi_context, ); let meta_config = MintActionMetaConfigCpiWrite { diff --git a/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs b/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs index d9331947b2..8ff01ce917 100644 --- a/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs +++ b/sdk-libs/ctoken-sdk/src/ctoken/create_cmint.rs @@ -121,6 +121,7 @@ impl CreateCMint { version: 3, mint: self.params.mint.to_bytes().into(), cmint_decompressed: false, + compressed_address: compression_address, }, mint_authority: Some(self.params.mint_authority.to_bytes().into()), freeze_authority: self @@ -132,7 +133,6 @@ impl CreateCMint { let mut instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - compression_address, self.params.address_merkle_tree_root_index, self.params.proof, compressed_mint_instruction_data, @@ -262,6 +262,7 @@ impl CreateCompressedMintCpiWrite { version: self.params.version, mint: self.params.mint.to_bytes().into(), cmint_decompressed: false, + compressed_address: self.params.compression_address, }, mint_authority: Some(self.params.mint_authority.to_bytes().into()), freeze_authority: self @@ -273,7 +274,6 @@ impl CreateCompressedMintCpiWrite { let instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint_write_to_cpi_context( - self.params.compression_address, self.params.address_merkle_tree_root_index, compressed_mint_instruction_data, self.params.cpi_context, diff --git a/sdk-libs/token-client/src/instructions/mint_action.rs b/sdk-libs/token-client/src/instructions/mint_action.rs index 14382da97b..50c5fcb15f 100644 --- a/sdk-libs/token-client/src/instructions/mint_action.rs +++ b/sdk-libs/token-client/src/instructions/mint_action.rs @@ -158,6 +158,7 @@ pub async fn create_mint_action_instruction( mint: find_cmint_address(¶ms.mint_seed).0.to_bytes().into(), // false for new mint - on-chain sets to true after DecompressMint cmint_decompressed: false, + compressed_address: params.compressed_mint_address, }, mint_authority: Some(new_mint.mint_authority.to_bytes().into()), freeze_authority: new_mint.freeze_authority.map(|auth| auth.to_bytes().into()), @@ -223,7 +224,6 @@ pub async fn create_mint_action_instruction( // Build instruction data using builder pattern let mut instruction_data = if is_creating_mint { MintActionCompressedInstructionData::new_mint( - params.compressed_mint_address, compressed_mint_inputs.root_index, proof.ok_or_else(|| { RpcError::CustomError("Proof is required for mint creation".to_string()) diff --git a/sdk-tests/csdk-anchor-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-derived-test/src/lib.rs index 37e4aeff2c..b49ea8307f 100644 --- a/sdk-tests/csdk-anchor-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-derived-test/src/lib.rs @@ -145,7 +145,6 @@ pub mod csdk_anchor_derived_test { // Build instruction data using the correct API let proof = compression_params.proof.0.unwrap_or_default(); let instruction_data = MintActionCompressedInstructionData::new_mint( - compression_params.mint_with_context.address, 0, // root_index for new addresses proof, compression_params.mint_with_context.mint.clone().unwrap(), diff --git a/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs index d9c940ca78..b5e40fbf03 100644 --- a/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs @@ -670,6 +670,7 @@ pub async fn create_user_record_and_game_session( version: 3, mint: spl_mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index ac4ca4fcd3..6f75ef39eb 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -186,7 +186,6 @@ pub mod csdk_anchor_full_derived_test { // Build instruction data using the correct API let proof = compression_params.proof.0.unwrap_or_default(); let instruction_data = MintActionCompressedInstructionData::new_mint( - compression_params.mint_with_context.address, 0, // root_index for new addresses proof, compression_params.mint_with_context.mint.clone().unwrap(), diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index 4c427b933e..ad7527679e 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -330,6 +330,7 @@ pub async fn create_user_record_and_game_session( version: 3, mint: spl_mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), diff --git a/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs b/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs index 755a1d2bc0..a475baf412 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/create_user_record_and_game_session.rs @@ -124,7 +124,6 @@ pub fn create_user_record_and_game_session<'info>( let proof = compression_params.proof.0.unwrap_or_default(); let mut instruction_data = light_ctoken_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - compression_params.mint_with_context.address, 0, // root_index proof, compression_params.mint_with_context.mint.clone().unwrap(), diff --git a/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs b/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs index b949f58d95..16f17de744 100644 --- a/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs @@ -334,6 +334,7 @@ pub async fn create_user_record_and_game_session( version: 3, mint: spl_mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.into()), freeze_authority: Some(freeze_authority.into()), diff --git a/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs b/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs index 2691c18584..9ae8a1460e 100644 --- a/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs +++ b/sdk-tests/sdk-token-test/src/ctoken_pda/mint.rs @@ -17,7 +17,6 @@ pub fn process_mint_action<'a, 'info>( ) -> Result<()> { // Build instruction data using builder pattern let mut instruction_data = MintActionCompressedInstructionData::new_mint( - input.compressed_mint_with_context.address, input.compressed_mint_with_context.root_index, light_compressed_account::instruction_data::compressed_proof::CompressedProof::default(), // Dummy proof for CPI write input.compressed_mint_with_context.mint.clone().unwrap(), diff --git a/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs b/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs index e34ec3c079..86f16caab8 100644 --- a/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs +++ b/sdk-tests/sdk-token-test/src/pda_ctoken/mint.rs @@ -19,7 +19,6 @@ pub fn process_mint_action<'a, 'info>( // ValidityProof is a wrapper around Option let compressed_proof = input.pda_creation.proof.0.unwrap(); let instruction_data = MintActionCompressedInstructionData::new_mint( - input.compressed_mint_with_context.address, input.compressed_mint_with_context.root_index, compressed_proof, input.compressed_mint_with_context.mint.clone().unwrap(), diff --git a/sdk-tests/sdk-token-test/tests/ctoken_pda.rs b/sdk-tests/sdk-token-test/tests/ctoken_pda.rs index 20e73f0c76..9f3be17fee 100644 --- a/sdk-tests/sdk-token-test/tests/ctoken_pda.rs +++ b/sdk-tests/sdk-token-test/tests/ctoken_pda.rs @@ -202,6 +202,7 @@ pub async fn create_mint( version: 3, mint: mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: freeze_authority.map(|fa| fa.into()), diff --git a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs index d2f15772b6..513baff1c1 100644 --- a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs +++ b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs @@ -275,6 +275,7 @@ pub async fn create_mint( version: 3, mint: mint.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, mint_authority: Some(mint_authority.pubkey().into()), freeze_authority: freeze_authority.map(|fa| fa.into()), diff --git a/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs b/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs index 01be945744..a9f0db4ad4 100644 --- a/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs +++ b/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs @@ -241,8 +241,9 @@ async fn mint_compressed_tokens( version: 3, mint: mint_pda.into(), cmint_decompressed: false, + compressed_address: compressed_mint_account.address.unwrap(), }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: Default::default(), extensions: None, diff --git a/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs b/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs index f70f8ed67f..39eea72671 100644 --- a/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs +++ b/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs @@ -132,8 +132,9 @@ async fn test_compress_full_and_close() { version: 3, mint: mint_pda.into(), cmint_decompressed: false, + compressed_address: compressed_mint_address, }, - reserved: [0u8; 49], + reserved: [0u8; 17], account_type: ACCOUNT_TYPE_MINT, compression: Default::default(), extensions: None, From d1550ad08b5aaeb52d553704924a384fe1d029db Mon Sep 17 00:00:00 2001 From: ananas Date: Thu, 8 Jan 2026 00:06:31 +0000 Subject: [PATCH 20/38] fix: adapt TypeScript MintAction layout to match Rust instruction data - Move compressedAddress from top-level to mint.metadata - Remove tokenPoolBump and tokenPoolIndex from instruction data - Update all instruction builders and tests accordingly --- .../src/v3/instructions/create-mint.ts | 4 +- .../src/v3/instructions/mint-to-compressed.ts | 4 +- .../src/v3/instructions/mint-to.ts | 4 +- .../src/v3/instructions/update-metadata.ts | 4 +- .../src/v3/instructions/update-mint.ts | 4 +- .../src/v3/layout/layout-mint-action.ts | 8 +-- .../tests/unit/layout-mint-action.test.ts | 29 +++------- .../tests/unit/mint-action-layout.test.ts | 53 ++++++------------- 8 files changed, 30 insertions(+), 80 deletions(-) diff --git a/js/compressed-token/src/v3/instructions/create-mint.ts b/js/compressed-token/src/v3/instructions/create-mint.ts index cd15abb504..e8d502f1aa 100644 --- a/js/compressed-token/src/v3/instructions/create-mint.ts +++ b/js/compressed-token/src/v3/instructions/create-mint.ts @@ -126,9 +126,6 @@ export function encodeCreateMintInstructionData( leafIndex: 0, proveByIndex: false, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -144,6 +141,7 @@ export function encodeCreateMintInstructionData( version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: splMintPda, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintAuthority, freezeAuthority: params.freezeAuthority, diff --git a/js/compressed-token/src/v3/instructions/mint-to-compressed.ts b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts index 61bae4e274..427cec5af5 100644 --- a/js/compressed-token/src/v3/instructions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts @@ -53,9 +53,6 @@ function encodeCompressedMintToInstructionData( leafIndex: params.leafIndex, proveByIndex: true, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [ @@ -78,6 +75,7 @@ function encodeCompressedMintToInstructionData( version: params.mintData.version, cmintDecompressed: params.mintData.cmintDecompressed, mint: params.mintData.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintData.mintAuthority, freezeAuthority: params.mintData.freezeAuthority, diff --git a/js/compressed-token/src/v3/instructions/mint-to.ts b/js/compressed-token/src/v3/instructions/mint-to.ts index 56b5aef32d..f72c4a399e 100644 --- a/js/compressed-token/src/v3/instructions/mint-to.ts +++ b/js/compressed-token/src/v3/instructions/mint-to.ts @@ -51,9 +51,6 @@ function encodeMintToCTokenInstructionData( leafIndex: params.leafIndex, proveByIndex: true, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [ @@ -73,6 +70,7 @@ function encodeMintToCTokenInstructionData( version: params.mintData.version, cmintDecompressed: params.mintData.cmintDecompressed, mint: params.mintData.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintData.mintAuthority, freezeAuthority: params.mintData.freezeAuthority, diff --git a/js/compressed-token/src/v3/instructions/update-metadata.ts b/js/compressed-token/src/v3/instructions/update-metadata.ts index 5ceee40814..2673aa22af 100644 --- a/js/compressed-token/src/v3/instructions/update-metadata.ts +++ b/js/compressed-token/src/v3/instructions/update-metadata.ts @@ -100,9 +100,6 @@ function encodeUpdateMetadataInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proof === null, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [convertActionToBorsh(params.action)], @@ -115,6 +112,7 @@ function encodeUpdateMetadataInstructionData( version: mintInterface.mintContext!.version, cmintDecompressed: mintInterface.mintContext!.cmintDecompressed, mint: mintInterface.mintContext!.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: mintInterface.mint.mintAuthority, freezeAuthority: mintInterface.mint.freezeAuthority, diff --git a/js/compressed-token/src/v3/instructions/update-mint.ts b/js/compressed-token/src/v3/instructions/update-mint.ts index 7e09096911..be962d804c 100644 --- a/js/compressed-token/src/v3/instructions/update-mint.ts +++ b/js/compressed-token/src/v3/instructions/update-mint.ts @@ -73,9 +73,6 @@ function encodeUpdateMintInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proveByIndex, rootIndex: params.rootIndex, - compressedAddress: Array.from(compressedAddress.toBytes()), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [action], @@ -89,6 +86,7 @@ function encodeUpdateMintInstructionData( cmintDecompressed: params.mintInterface.mintContext!.cmintDecompressed, mint: params.mintInterface.mintContext!.splMint, + compressedAddress: Array.from(compressedAddress.toBytes()), }, mintAuthority: params.mintInterface.mint.mintAuthority, freezeAuthority: params.mintInterface.mint.freezeAuthority, diff --git a/js/compressed-token/src/v3/layout/layout-mint-action.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts index 5c169ce306..8ad1ec9bc2 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -142,6 +142,7 @@ export const CompressedMintMetadataLayout = struct([ u8('version'), bool('cmintDecompressed'), publicKey('mint'), + array(u8(), 32, 'compressedAddress'), ]); export const CompressedMintInstructionDataLayout = struct([ @@ -157,9 +158,6 @@ export const MintActionCompressedInstructionDataLayout = struct([ u32('leafIndex'), bool('proveByIndex'), u16('rootIndex'), - array(u8(), 32, 'compressedAddress'), - u8('tokenPoolBump'), - u8('tokenPoolIndex'), u16('maxTopUp'), option(CreateMintLayout, 'createMint'), vec(ActionLayout, 'actions'), @@ -311,6 +309,7 @@ export interface CompressedMintMetadata { version: number; cmintDecompressed: boolean; mint: PublicKey; + compressedAddress: number[]; } export interface CompressedMintInstructionData { @@ -326,9 +325,6 @@ export interface MintActionCompressedInstructionData { leafIndex: number; proveByIndex: boolean; rootIndex: number; - compressedAddress: number[]; - tokenPoolBump: number; - tokenPoolIndex: number; maxTopUp: number; createMint: CreateMint | null; actions: Action[]; diff --git a/js/compressed-token/tests/unit/layout-mint-action.test.ts b/js/compressed-token/tests/unit/layout-mint-action.test.ts index e01c180e1e..23b444e074 100644 --- a/js/compressed-token/tests/unit/layout-mint-action.test.ts +++ b/js/compressed-token/tests/unit/layout-mint-action.test.ts @@ -17,9 +17,6 @@ describe('layout-mint-action', () => { leafIndex: 100, proveByIndex: true, rootIndex: 5, - compressedAddress: Array(32).fill(1), - tokenPoolBump: 255, - tokenPoolIndex: 0, maxTopUp: 1000, createMint: null, actions: [], @@ -32,6 +29,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(1), }, mintAuthority: mint, freezeAuthority: null, @@ -49,7 +47,6 @@ describe('layout-mint-action', () => { expect(decoded.leafIndex).toBe(100); expect(decoded.proveByIndex).toBe(true); expect(decoded.rootIndex).toBe(5); - expect(decoded.tokenPoolBump).toBe(255); expect(decoded.maxTopUp).toBe(1000); expect(decoded.actions.length).toBe(0); expect(decoded.mint.decimals).toBe(9); @@ -74,9 +71,6 @@ describe('layout-mint-action', () => { leafIndex: 50, proveByIndex: false, rootIndex: 10, - compressedAddress: Array(32).fill(2), - tokenPoolBump: 254, - tokenPoolIndex: 1, maxTopUp: 500, createMint: null, actions: [mintToCompressedAction], @@ -89,6 +83,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: false, mint, + compressedAddress: Array(32).fill(2), }, mintAuthority: mint, freezeAuthority: null, @@ -126,9 +121,6 @@ describe('layout-mint-action', () => { leafIndex: 0, proveByIndex: true, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 253, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [mintToCTokenAction], @@ -141,6 +133,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(0), }, mintAuthority: mint, freezeAuthority: null, @@ -174,9 +167,6 @@ describe('layout-mint-action', () => { leafIndex: 10, proveByIndex: true, rootIndex: 2, - compressedAddress: Array(32).fill(5), - tokenPoolBump: 250, - tokenPoolIndex: 0, maxTopUp: 100, createMint: null, actions: [updateAction], @@ -189,6 +179,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(5), }, mintAuthority: mint, freezeAuthority: null, @@ -225,9 +216,6 @@ describe('layout-mint-action', () => { leafIndex: 5, proveByIndex: true, rootIndex: 1, - compressedAddress: Array(32).fill(7), - tokenPoolBump: 200, - tokenPoolIndex: 2, maxTopUp: 50, createMint: null, actions, @@ -240,6 +228,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(7), }, mintAuthority: mint, freezeAuthority: null, @@ -261,9 +250,6 @@ describe('layout-mint-action', () => { leafIndex: 0, proveByIndex: true, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 255, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [], @@ -276,6 +262,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(0), }, mintAuthority: mint, freezeAuthority: null, @@ -297,9 +284,6 @@ describe('layout-mint-action', () => { leafIndex: 0, proveByIndex: true, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 255, - tokenPoolIndex: 0, maxTopUp: 0, createMint: null, actions: [], @@ -322,6 +306,7 @@ describe('layout-mint-action', () => { version: 1, cmintDecompressed: true, mint, + compressedAddress: Array(32).fill(0), }, mintAuthority: mint, freezeAuthority: null, diff --git a/js/compressed-token/tests/unit/mint-action-layout.test.ts b/js/compressed-token/tests/unit/mint-action-layout.test.ts index 564012e314..865397f46f 100644 --- a/js/compressed-token/tests/unit/mint-action-layout.test.ts +++ b/js/compressed-token/tests/unit/mint-action-layout.test.ts @@ -27,9 +27,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 42, - compressedAddress: Array.from(new Uint8Array(32).fill(1)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -49,6 +46,7 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from(new Uint8Array(32).fill(1)), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -65,11 +63,6 @@ describe('MintActionCompressedInstructionData Layout', () => { expect(decoded.leafIndex).toBe(instructionData.leafIndex); expect(decoded.proveByIndex).toBe(instructionData.proveByIndex); expect(decoded.rootIndex).toBe(instructionData.rootIndex); - expect(decoded.compressedAddress).toEqual( - instructionData.compressedAddress, - ); - expect(decoded.tokenPoolBump).toBe(instructionData.tokenPoolBump); - expect(decoded.tokenPoolIndex).toBe(instructionData.tokenPoolIndex); expect(decoded.maxTopUp).toBe(instructionData.maxTopUp); expect(decoded.createMint).toEqual(instructionData.createMint); expect(decoded.actions).toEqual([]); @@ -77,6 +70,9 @@ describe('MintActionCompressedInstructionData Layout', () => { expect(decoded.cpiContext).toBeNull(); expect(decoded.mint).toBeDefined(); expect(decoded.mint!.decimals).toBe(9); + expect(decoded.mint!.metadata.compressedAddress).toEqual( + instructionData.mint!.metadata.compressedAddress, + ); }); it('should encode createMint without proof (null proof)', () => { @@ -87,9 +83,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 0, - compressedAddress: Array.from(new Uint8Array(32).fill(0)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -105,6 +98,7 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from(new Uint8Array(32).fill(0)), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -128,9 +122,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 100, - compressedAddress: Array.from(new Uint8Array(32).fill(5)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -150,6 +141,7 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from(new Uint8Array(32).fill(5)), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: freezeAuthority.publicKey, @@ -174,9 +166,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 50, - compressedAddress: Array.from(new Uint8Array(32).fill(6)), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -196,6 +185,7 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, + compressedAddress: Array.from(new Uint8Array(32).fill(6)), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -236,9 +226,6 @@ describe('MintActionCompressedInstructionData Layout', () => { leafIndex: 0, proveByIndex: false, rootIndex: 0, - compressedAddress: Array(32).fill(0), - tokenPoolBump: 0, - tokenPoolIndex: 0, maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], @@ -254,6 +241,7 @@ describe('MintActionCompressedInstructionData Layout', () => { version: 0, cmintDecompressed: false, mint: mintSigner, + compressedAddress: Array(32).fill(0), }, mintAuthority: mintAuthority, freezeAuthority: null, @@ -286,26 +274,17 @@ describe('MintActionCompressedInstructionData Layout', () => { // Next 2 bytes should be rootIndex (0 as u16 little-endian) expect(encoded1.slice(6, 8)).toEqual(Buffer.from([0, 0])); - // Next 32 bytes should be compressedAddress (all zeros) - expect(encoded1.slice(8, 40)).toEqual(Buffer.alloc(32, 0)); - - // tokenPoolBump at byte 40 - expect(encoded1[40]).toBe(0); - - // tokenPoolIndex at byte 41 - expect(encoded1[41]).toBe(0); - - // maxTopUp at bytes 42-43 (u16 little-endian) - expect(encoded1.slice(42, 44)).toEqual(Buffer.from([0, 0])); + // maxTopUp at bytes 8-9 (u16 little-endian) + expect(encoded1.slice(8, 10)).toEqual(Buffer.from([0, 0])); - // createMint Option: byte 44 should be 1 (Some) - expect(encoded1[44]).toBe(1); + // createMint Option: byte 10 should be 1 (Some) + expect(encoded1[10]).toBe(1); - // createMint.readOnlyAddressTrees: bytes 45-48 - expect(encoded1.slice(45, 49)).toEqual(Buffer.from([0, 0, 0, 0])); + // createMint.readOnlyAddressTrees: bytes 11-14 + expect(encoded1.slice(11, 15)).toEqual(Buffer.from([0, 0, 0, 0])); - // createMint.readOnlyAddressTreeRootIndices: bytes 49-56 (4 x u16) - expect(encoded1.slice(49, 57)).toEqual( + // createMint.readOnlyAddressTreeRootIndices: bytes 15-22 (4 x u16) + expect(encoded1.slice(15, 23)).toEqual( Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]), ); }); From 54c2523c86386aad9227f7bc8db8c16653e11295 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 8 Jan 2026 19:23:02 +0000 Subject: [PATCH 21/38] review 1 --- program-libs/compressible/src/rent/config.rs | 8 ++--- .../mint_action/decompress_mint.rs | 5 ++-- .../src/state/ctoken/ctoken_struct.rs | 1 + .../src/state/ctoken/zero_copy.rs | 12 ++++++-- .../src/token_2022_extensions.rs | 2 +- .../compressed_token/mint_action/accounts.rs | 17 ++++++----- .../actions/compress_and_close_cmint.rs | 2 +- .../mint_action/actions/decompress_mint.rs | 6 ++++ .../mint_action/mint_output.rs | 3 +- .../transfer2/check_extensions.rs | 11 +++++-- .../ctoken/compress_or_decompress_ctokens.rs | 4 +-- .../transfer2/compression/mod.rs | 2 +- .../src/compressed_token/transfer2/config.rs | 1 - .../compressed_token/transfer2/processor.rs | 4 +-- .../program/src/ctoken/close/processor.rs | 29 ++++++++++++------- .../program/src/ctoken/create.rs | 1 - .../program/src/ctoken/transfer/shared.rs | 3 ++ .../src/extensions/check_mint_extensions.rs | 2 +- .../program/src/shared/compressible_top_up.rs | 7 ++++- .../src/shared/initialize_ctoken_account.rs | 2 +- 20 files changed, 77 insertions(+), 45 deletions(-) diff --git a/program-libs/compressible/src/rent/config.rs b/program-libs/compressible/src/rent/config.rs index 36e720a65d..1b0a5ca35a 100644 --- a/program-libs/compressible/src/rent/config.rs +++ b/program-libs/compressible/src/rent/config.rs @@ -6,10 +6,10 @@ use crate::{AnchorDeserialize, AnchorSerialize}; pub const COMPRESSION_COST: u16 = 10_000; pub const COMPRESSION_INCENTIVE: u16 = 1000; -pub const BASE_RENT: u16 = 128; -pub const RENT_PER_BYTE: u8 = 1; -// Epoch duration: 1.5 hours, 90 minutes * 60 seconds / 0.4 seconds per slot = 13,500 slots per epoch -pub const SLOTS_PER_EPOCH: u64 = 13500; +pub const BASE_RENT: u16 = 128; // TODO: multiply by 10 +pub const RENT_PER_BYTE: u8 = 1; // TODO: multiply by 10 + // Epoch duration: 1.5 hours, 90 minutes * 60 seconds / 0.4 seconds per slot = 13,500 slots per epoch +pub const SLOTS_PER_EPOCH: u64 = 13500; // TODO: multiply by 10 /// Trait for accessing rent configuration parameters. /// diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs b/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs index 3a64546250..2233caf44d 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs @@ -12,11 +12,10 @@ use crate::{AnchorDeserialize, AnchorSerialize}; pub struct DecompressMintAction { /// PDA bump for CMint account verification pub cmint_bump: u8, - /// Rent payment in epochs (prepaid). REQUIRED field. - /// CMint is ALWAYS compressible - must be >= 2. - /// NOTE: rent_payment == 0 or 1 is REJECTED. + /// Rent payment in epochs (prepaid). pub rent_payment: u8, /// Lamports allocated for future write operations (top-up per write). /// Must not exceed config.rent_config.max_top_up. pub write_top_up: u32, + // TODO: mint signer shouldnt need to sign for decompress_mint } diff --git a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs index 83f6ee55f0..f844d55b28 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs @@ -83,6 +83,7 @@ impl CToken { let data = account_info .try_borrow_data() .map_err(|_| ZeroCopyError::Size)?; + // TODO: check account is a ctoken account Self::amount_from_slice(&data) } diff --git a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs index 623f23cc34..2062d7067d 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs @@ -110,6 +110,7 @@ impl<'a> ZeroCopyNew<'a> for CToken { bytes: &'a mut [u8], config: Self::ZeroCopyConfig, ) -> Result<(Self::Output, &'a mut [u8]), light_zero_copy::errors::ZeroCopyError> { + // TODO: check that this function fails if the account is already initialized // Use derived new_zero_copy for base struct (config type is () for fixed-size struct) let (mut base, mut remaining) = >::new_zero_copy(bytes, ())?; @@ -157,9 +158,12 @@ impl<'a> ZeroCopyNew<'a> for CToken { remaining = write_remaining; (ACCOUNT_TYPE_TOKEN_ACCOUNT, Some(parsed_extensions)) } else { + // TODO: remaining bytes should be checked to be zero (ACCOUNT_TYPE_TOKEN_ACCOUNT, None) }; - + if !remaining.is_empty() { + return Err(light_zero_copy::errors::ZeroCopyError::Size); + } Ok(( ZCTokenMut { base, @@ -181,7 +185,8 @@ impl<'a> ZeroCopyAt<'a> for CToken { let (base, bytes) = >::zero_copy_at(bytes)?; // Check if there are extensions by looking at account_type byte at position 165 - if !bytes.is_empty() && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT { + if !bytes.is_empty() { + // && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT should throw an error let account_type = bytes[0]; // Skip account_type byte let bytes = &bytes[1..]; @@ -221,7 +226,8 @@ impl<'a> ZeroCopyAtMut<'a> for CToken { let (base, bytes) = >::zero_copy_at_mut(bytes)?; // Check if there are extensions by looking at account_type byte at position 165 - if !bytes.is_empty() && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT { + if !bytes.is_empty() { + // && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT should throw an error let account_type = bytes[0]; // Skip account_type byte let bytes = &mut bytes[1..]; diff --git a/program-libs/ctoken-interface/src/token_2022_extensions.rs b/program-libs/ctoken-interface/src/token_2022_extensions.rs index 30755073ae..f36f8857b3 100644 --- a/program-libs/ctoken-interface/src/token_2022_extensions.rs +++ b/program-libs/ctoken-interface/src/token_2022_extensions.rs @@ -74,7 +74,7 @@ pub struct MintExtensionFlags { } impl MintExtensionFlags { - pub fn num_extensions(&self) -> usize { + pub fn num_token_account_extensions(&self) -> usize { let mut count = 0; if self.has_pausable { count += 1; diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index b504bb7ac2..337e0604db 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -312,6 +312,7 @@ impl<'info> MintActionAccounts<'info> { .as_ref() .ok_or(ProgramError::NotEnoughAccountKeys)?; + // TODO: check whether we can simplify or move to decompress action processor. // When cmint_pubkey is provided, verify CMint account matches // When None (mint data from CMint), skip - CMint is validated when reading its data if let (Some(cmint_account), Some(expected_pubkey)) = (accounts.cmint, cmint_pubkey) { @@ -390,7 +391,7 @@ impl AccountsConfig { /// decompress_mint only needs mint_signer.key() for PDA derivation. #[inline(always)] pub fn mint_signer_must_sign(&self) -> bool { - self.with_mint_signer && !self.has_decompress_mint_action + self.create_mint } /// Initialize AccountsConfig based in instruction data. - @@ -455,19 +456,19 @@ impl AccountsConfig { msg!("Mint to ctokens not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } - if has_decompress_mint_action || cmint_decompressed { + if has_decompress_mint_action { msg!("Decompress mint not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } + + if cmint_decompressed { + msg!("CMint decompressed not allowed when writing to cpi context"); + return Err(ErrorCode::CpiContextSetNotUsable.into()); + } let has_mint_to_actions = parsed_instruction_data .actions .iter() .any(|action| matches!(action, ZAction::MintToCompressed(_))); - if cmint_decompressed && has_mint_to_actions { - msg!("Mint to compressed not allowed if cmint decompressed when writing to cpi context"); - return Err(ErrorCode::CpiContextSetNotUsable.into()); - } - Ok(AccountsConfig { with_cpi_context, write_to_cpi_context, @@ -491,7 +492,7 @@ impl AccountsConfig { with_cpi_context, write_to_cpi_context, cmint_decompressed, - has_mint_to_actions, + has_mint_to_actions, // TODO: evaluate wether to rename to require token output queue with_mint_signer, create_mint: parsed_instruction_data.create_mint.is_some(), has_decompress_mint_action, diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs index d2d1a1004a..549bb5126f 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs @@ -40,7 +40,7 @@ pub fn process_compress_and_close_cmint_action( ) -> Result<(), ProgramError> { // NOTE: CompressAndCloseCMint is permissionless - anyone can compress if is_compressible() returns true // All lamports returned to rent_sponsor - + // TODO: test idempotent, it should exit the complete instruction silently. // 1. Idempotent check - if CMint doesn't exist and idempotent is set, succeed silently if action.is_idempotent() && !compressed_mint.metadata.cmint_decompressed { return Ok(()); diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index 79c366aac6..756097e645 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -1,5 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; +use light_array_map::pubkey_eq; use light_compressible::compression_info::CompressionInfo; use light_ctoken_interface::{ instructions::mint_action::ZDecompressMintAction, state::CompressedMint, COMPRESSED_MINT_SEED, @@ -116,6 +117,11 @@ pub fn process_decompress_mint_action( action.cmint_bump, &crate::LIGHT_CPI_SIGNER.program_id, )?; + // 6b. Verify CMint account matches compressed_mint.metadata.mint + if !pubkey_eq(cmint.key(), &compressed_mint.metadata.mint.to_bytes()) { + msg!("CMint account does not match compressed_mint.metadata.mint"); + return Err(ErrorCode::InvalidCMintAccount.into()); + } // 7. Account creation: rent_sponsor pays rent exemption, fee_payer pays Light rent // 7a. Calculate account size AFTER adding extension (using borsh serialization) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 9122b91f03..d0ede11113 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -153,6 +153,7 @@ fn serialize_decompressed_mint( .as_ref() .map(|exec| exec.system.fee_payer) .ok_or(ProgramError::NotEnoughAccountKeys)?; + // TODO: unify with other transfer and move top up after resize to calculate based on new size. transfer_lamports(top_up, fee_payer, cmint_account).map_err(convert_program_error)?; } } @@ -163,7 +164,7 @@ fn serialize_decompressed_mint( let required_size = serialized.len(); // Resize if needed (e.g., metadata extensions added) - if cmint_account.data_len() < required_size { + if cmint_account.data_len() != required_size { cmint_account .resize(required_size) .map_err(|_| ErrorCode::CMintResizeFailed)?; diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs index cf2db23fde..fb6dbba876 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs @@ -1,3 +1,5 @@ +use core::panic; + use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; @@ -101,7 +103,6 @@ pub fn build_mint_extension_cache<'a>( if let Some(compressions) = inputs.compressions.as_ref() { for compression in compressions.iter() { let mint_index = compression.mint; - if cache.get_by_key(&mint_index).is_none() { let mint_account = packed_accounts.get_u8(mint_index, "mint cache: compression")?; let checks = if compression.mode.is_compress_and_close() || no_compressed_outputs { @@ -111,7 +112,10 @@ pub fn build_mint_extension_cache<'a>( } else { check_mint_extensions(mint_account, deny_restricted_extensions)? }; + cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; + } + if let Some(checks) = cache.get_by_key(&mint_index) { // CompressAndClose with restricted extensions requires CompressedOnly output. // Compress/Decompress don't need additional validation here: // - Compress: blocked by check_mint_extensions when outputs exist @@ -134,8 +138,9 @@ pub fn build_mint_extension_cache<'a>( ); } } - - cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; + } else { + // TODO: double check. + panic!("Mint cache: compression: mint index not found"); } } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index 2270a717d4..36d9b2e996 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -135,13 +135,13 @@ fn validate_ctoken( } // Reject uninitialized accounts (state == 0) - if ctoken.base.state == 0 { + if !ctoken.is_initialized() { msg!("Account is uninitialized"); return Err(CTokenError::InvalidAccountState.into()); } // Check if account is frozen (SPL Token-2022 compatibility) // Frozen accounts cannot have their balance modified except for CompressAndClose - else if ctoken.base.state == 2 && !mode.is_compress_and_close() { + else if ctoken.is_frozen() && !mode.is_compress_and_close() { msg!("Cannot modify frozen account"); return Err(ErrorCode::AccountFrozen.into()); } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index c80a39df17..10175e3fbf 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -116,7 +116,7 @@ pub fn process_token_compression<'a>( SPL_TOKEN_2022_ID => { // CompressedOnly inputs must decompress to CToken accounts to preserve // extension state (frozen, delegated, withheld fees). - if compression.mode.is_decompress() + if compression.mode.is_decompress() // TODO: double check that we need decompress check here. && compression_to_input[compression_index].is_some() { msg!("CompressedOnly inputs must decompress to CToken account"); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs index a04c239ede..8dc242b060 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs @@ -24,7 +24,6 @@ pub struct Transfer2Config { impl Transfer2Config { /// Create configuration from instruction data - /// Centralizes the boolean logic that was previously scattered in processor pub fn from_instruction_data( inputs: &ZCompressedTokenInstructionDataTransfer2, ) -> Result { diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 8c9b2800bc..d33ee2dcdf 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -61,7 +61,7 @@ pub fn process_transfer2( if transfer_config.no_compressed_accounts { // No compressed accounts are invalidated or created in this transaction - // -> no need to invoke the light system program. + // so no need to invoke the light system program. process_no_system_program_cpi(&inputs, &validated_accounts, &mint_cache) } else { process_with_system_program_cpi( @@ -140,7 +140,7 @@ pub fn validate_instruction_data( if !allowed { return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); } - // All out_token_data must be version 3 if tlv is present. + // All out_token_data must be version 3 (sha flat) if tlv is present. let allowed = inputs.out_token_data.iter().all(|c| c.version == 3); if !allowed { return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); diff --git a/programs/compressed-token/program/src/ctoken/close/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs index e9c36c9fd3..d3c0e2ea8a 100644 --- a/programs/compressed-token/program/src/ctoken/close/processor.rs +++ b/programs/compressed-token/program/src/ctoken/close/processor.rs @@ -1,6 +1,9 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; -use light_account_checks::{checks::check_signer, AccountInfoTrait}; +use light_account_checks::{ + checks::{check_owner, check_signer}, + AccountInfoTrait, +}; use light_compressible::rent::{get_rent_exemption_lamports, AccountRentState}; use light_ctoken_interface::state::{AccountState, CToken, ZCTokenMut}; use light_program_profiler::profile; @@ -10,7 +13,10 @@ use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; use spl_pod::solana_msg::msg; use super::accounts::CloseTokenAccountAccounts; -use crate::shared::{convert_program_error, transfer_lamports}; +use crate::{ + shared::{convert_program_error, transfer_lamports}, + LIGHT_CPI_SIGNER, +}; /// Process the close token account instruction #[profile] @@ -67,15 +73,18 @@ fn validate_token_account( if u64::from(ctoken.amount) != 0 { return Err(ErrorCode::NonNativeHasBalance.into()); } - // TODO: Non-zero transfer fees not yet supported. If fees != 0 support is added: + // Note: Non-zero transfer fees are not yet supported. If fees != 0 support is added: // - Check TransferFeeAccount.withheld_amount == 0 before allowing close // - Implement harvest_withheld_fees instruction to extract fees first // - T22 blocks close when withheld_amount > 0 to prevent fee loss } // Check for Compressible extension let compressible = ctoken.get_compressible_extension(); - + // TODO: extract into separate function and remove const generic if COMPRESS_AND_CLOSE { + if accounts.token_account.key() == accounts.destination.key() { + return Err(ProgramError::InvalidAccountData); + } // CompressAndClose requires Compressible extension let compression = compressible.ok_or_else(|| { msg!("compress and close requires compressible extension"); @@ -91,19 +100,16 @@ fn validate_token_account( return Err(ProgramError::InvalidAccountData); } - // For CompressAndClose: ONLY compression_authority can compress and close if compression.info.compression_authority != *accounts.authority.key() { msg!("compress and close requires compression authority"); return Err(ProgramError::InvalidAccountData); } - #[cfg(target_os = "solana")] - let current_slot = pinocchio::sysvars::clock::Clock::get() - .map_err(convert_program_error)? - .slot; - #[cfg(target_os = "solana")] { + let current_slot = pinocchio::sysvars::clock::Clock::get() + .map_err(convert_program_error)? + .slot; let is_compressible = compression .info .is_compressible( @@ -124,7 +130,7 @@ fn validate_token_account( return Ok(compression.info.compress_to_pubkey() || compression.is_ata != 0); } - // For regular close: validate rent_sponsor if compressible + // For regular close: validate rent_sponsor if it has a compressible extension if let Some(compression) = compressible { let rent_sponsor = accounts .rent_sponsor @@ -186,6 +192,7 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( })?; // Check for compressible extension and handle lamport distribution + check_owner(&LIGHT_CPI_SIGNER.program_id, accounts.token_account)?; let token_account_data = AccountInfoTrait::try_borrow_data(accounts.token_account)?; let (ctoken, _) = CToken::zero_copy_at_checked(&token_account_data)?; diff --git a/programs/compressed-token/program/src/ctoken/create.rs b/programs/compressed-token/program/src/ctoken/create.rs index 32ff326756..cd36e95435 100644 --- a/programs/compressed-token/program/src/ctoken/create.rs +++ b/programs/compressed-token/program/src/ctoken/create.rs @@ -39,7 +39,6 @@ pub fn process_create_token_account( let is_compressible = inputs.compressible_config.is_some(); - // Parse and validate accounts let mut iter = AccountIterator::new(account_infos); // For compressible accounts: token_account must be signer (account created via CPI) diff --git a/programs/compressed-token/program/src/ctoken/transfer/shared.rs b/programs/compressed-token/program/src/ctoken/transfer/shared.rs index 8b1d4f5819..511c464895 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/shared.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/shared.rs @@ -34,6 +34,7 @@ impl AccountExtensionInfo { || self.flags.has_permanent_delegate != other.flags.has_permanent_delegate || self.flags.has_transfer_fee != other.flags.has_transfer_fee || self.flags.has_transfer_hook != other.flags.has_transfer_hook + || self.flags.has_default_account_state != other.flags.has_default_account_state { Err(ProgramError::InvalidInstructionData) } else { @@ -162,6 +163,7 @@ fn validate_sender( // Get mint checks if any account has extensions (single mint deserialization) let mint_checks = if sender_info.flags.has_restricted_extensions() { + // Transfer instruction with ctoken account with restricted extensions will fail here. let mint_account = transfer_accounts .mint .ok_or(ErrorCode::MintRequiredForTransfer)?; @@ -219,6 +221,7 @@ fn process_account_extensions( current_slot: &mut u64, mint: Option<&AccountInfo>, ) -> Result { + // TODO: replace with from_account_info_checked let mut account_data = account .try_borrow_mut_data() .map_err(convert_program_error)?; diff --git a/programs/compressed-token/program/src/extensions/check_mint_extensions.rs b/programs/compressed-token/program/src/extensions/check_mint_extensions.rs index 15c2e6cd24..9bf740e0e5 100644 --- a/programs/compressed-token/program/src/extensions/check_mint_extensions.rs +++ b/programs/compressed-token/program/src/extensions/check_mint_extensions.rs @@ -55,7 +55,7 @@ pub fn parse_mint_extensions( ) -> Result { // Only Token-2022 mints can have extensions if !mint_account.is_owned_by(&SPL_TOKEN_2022_ID) { - return Ok(MintExtensionChecks::default()); + return Err(ProgramError::InvalidAccountOwner); } let mint_data = AccountInfoTrait::try_borrow_data(mint_account)?; diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index bd57477bb0..a37dfdfb9b 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -1,4 +1,5 @@ use anchor_lang::solana_program::program_error::ProgramError; +use light_account_checks::checks::check_owner; use light_ctoken_interface::{ state::{CToken, CompressedMint}, CTokenError, @@ -9,6 +10,8 @@ use pinocchio::{ sysvars::{clock::Clock, rent::Rent, Sysvar}, }; +use crate::LIGHT_CPI_SIGNER; + use super::{ convert_program_error, transfer_lamports::{multi_transfer_lamports, Transfer}, @@ -47,8 +50,9 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( // Initialize budget: +1 allows exact match (total == max_top_up) let mut lamports_budget = (max_top_up as u64).saturating_add(1); - // Calculate CMint top-up using zero-copy + // Calculate CMint top-up { + check_owner(&LIGHT_CPI_SIGNER.program_id, cmint)?; let cmint_data = cmint.try_borrow_data().map_err(convert_program_error)?; let (mint, _) = CompressedMint::zero_copy_at_checked(&cmint_data) .map_err(|_| CTokenError::CMintDeserializationFailed)?; @@ -64,6 +68,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( // Calculate CToken top-up (only if not 165 bytes - 165 means no extensions) if ctoken.data_len() != 165 { + check_owner(&LIGHT_CPI_SIGNER.program_id, ctoken)?; // TODO: add from account info let account_data = ctoken.try_borrow_data().map_err(convert_program_error)?; let (token, _) = CToken::zero_copy_at_checked(&account_data)?; // Check for Compressible extension 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 c1b2fd01de..c1a109b00e 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -152,7 +152,7 @@ pub fn initialize_ctoken_account( // Build extensions Vec from boolean flags // +1 for potential Compressible extension - let mut extensions = Vec::with_capacity(mint_extensions.num_extensions() + 1); + let mut extensions = Vec::with_capacity(mint_extensions.num_token_account_extensions() + 1); // Add Compressible extension if compression is enabled if compressible.is_some() { extensions.push(ExtensionStructConfig::Compressible( From bbaad982b9dbce65b4c27b3754406308f0200a3c Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Fri, 9 Jan 2026 16:45:06 +0000 Subject: [PATCH 22/38] review 2 --- .../src/instructions/transfer2/compression.rs | 2 +- .../compressed-token/anchor/src/freeze.rs | 2 +- .../anchor/src/process_transfer.rs | 3 +- .../transfer2/check_extensions.rs | 9 +- .../compression/ctoken/compress_and_close.rs | 37 +++--- .../compression/ctoken/decompress.rs | 114 ++++++++++-------- .../transfer2/token_inputs.rs | 1 + .../program/src/ctoken/approve_revoke.rs | 2 +- .../program/src/shared/token_input.rs | 1 + 9 files changed, 95 insertions(+), 76 deletions(-) diff --git a/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs b/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs index 081940068a..03e89551f1 100644 --- a/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs +++ b/program-libs/ctoken-interface/src/instructions/transfer2/compression.rs @@ -13,7 +13,7 @@ pub enum CompressionMode { Compress, Decompress, /// Compresses ctoken account and closes it - /// Signer must be owner or rent authority, if rent authority ctoken account must be compressible + /// Signer must be rent authority, ctoken account must be compressible /// Not implemented for spl token accounts. CompressAndClose, } diff --git a/programs/compressed-token/anchor/src/freeze.rs b/programs/compressed-token/anchor/src/freeze.rs index b5a0e14689..f7f12f9fc4 100644 --- a/programs/compressed-token/anchor/src/freeze.rs +++ b/programs/compressed-token/anchor/src/freeze.rs @@ -75,7 +75,7 @@ pub fn process_freeze_or_thaw< ctx.remaining_accounts, version, )?; - // TODO: discuss + let proof = if inputs.proof == CompressedProof::default() { None } else { diff --git a/programs/compressed-token/anchor/src/process_transfer.rs b/programs/compressed-token/anchor/src/process_transfer.rs index bb61286ddc..a86ecd9bab 100644 --- a/programs/compressed-token/anchor/src/process_transfer.rs +++ b/programs/compressed-token/anchor/src/process_transfer.rs @@ -644,7 +644,7 @@ pub fn get_input_compressed_accounts_with_merkle_context_and_check_signer Result { // Validate TLV is only used with version 3 (ShaFlat) - if tlv_data.is_some_and(|v| !v.is_empty() && version != 3) { + if tlv_data.is_some_and(|v| !v.is_empty() && version != TokenDataVersion::ShaFlat as u8) { msg!("TLV extensions only supported with version 3 (ShaFlat)"); return Err(ErrorCode::TlvRequiresVersion3.into()); } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 29d8816861..d382c17404 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -7,7 +7,7 @@ use light_ctoken_interface::{ extensions::ZExtensionInstructionData, transfer2::{ZCompression, ZCompressionMode, ZMultiTokenTransferOutputData}, }, - state::{ZCTokenMut, ZExtensionStructMut}, + state::{TokenDataVersion, ZCTokenMut, ZExtensionStructMut}, CTokenError, }; use light_program_profiler::profile; @@ -68,11 +68,14 @@ pub fn process_compress_and_close( token_account_info.key(), close_inputs.tlv, )?; - - ctoken.base.amount.set(0); - // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) - // This allows the close_token_account validation to pass for frozen accounts - ctoken.base.set_initialized(); + // TODO: remove once we separated close logic for compress and close + //TODO: introduce ready to close state and set it here + { + ctoken.base.amount.set(0); + // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) + // This allows the close_token_account validation to pass for frozen accounts + ctoken.base.set_initialized(); + } Ok(()) } @@ -86,8 +89,13 @@ fn validate_compressed_token_account( token_account_pubkey: &Pubkey, out_tlv: Option<&[ZExtensionInstructionData<'_>]>, ) -> Result<(), ProgramError> { + let compression = ctoken + .get_compressible_extension() + .ok_or::(CTokenError::MissingCompressibleExtension.into())?; + let is_ata = compression.is_ata != 0; // Owners should match if not compressing to pubkey - if compress_to_pubkey { + if compress_to_pubkey || is_ata { + // what about is ata? // Owner should match token account pubkey if compressing to pubkey if *packed_accounts .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? @@ -155,16 +163,13 @@ fn validate_compressed_token_account( } // Version should be ShaFlat - if compressed_token_account.version != 3 { + if compressed_token_account.version != TokenDataVersion::ShaFlat as u8 { return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); } - let compression = ctoken - .get_compressible_extension() - .ok_or::(CTokenError::MissingCompressibleExtension.into())?; + // Version should also match what's specified in the embedded compression info let expected_version = compression.info.account_version; let compression_only = compression.compression_only(); - let is_ata = compression.is_ata != 0; if compressed_token_account.version != expected_version { return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); @@ -248,14 +253,10 @@ fn validate_compressed_token_account( } // Frozen state must match between CToken and extension data - // AccountState::Frozen = 2 in CToken - // ZeroCopy converts bool to u8: 0 = false, non-zero = true - let ctoken_is_frozen = ctoken.state == 2; - let extension_is_frozen = compression_only_extension.is_frozen != 0; - if extension_is_frozen != ctoken_is_frozen { + if ctoken.state != compression_only_extension.is_frozen { msg!( "is_frozen mismatch: ctoken {} != extension {}", - ctoken_is_frozen, + ctoken.state, compression_only_extension.is_frozen ); return Err(ErrorCode::CompressAndCloseFrozenMismatch.into()); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs index 19c2e25b8c..8d17a8bd58 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -32,45 +32,69 @@ fn validate_decompression_destination( ) -> Result<(), ProgramError> { // Owner must match (for non-ATA) or ATA must be correctly derived (for ATA) if ext_data.is_ata != 0 { - // For ATA decompress, we need the wallet_owner - let wallet_owner = wallet_owner.ok_or_else(|| { - msg!("ATA decompress requires wallet_owner from owner_index"); - CTokenError::DecompressDestinationMismatch - })?; - - // Wallet owner must be a signer - if !wallet_owner.is_signer() { - msg!("Wallet owner must be signer for ATA decompress"); - return Err(CTokenError::DecompressDestinationMismatch.into()); + // Move to input validation and pass in instruction token data + { + // For ATA decompress, we need the wallet_owner + let wallet_owner = wallet_owner.ok_or_else(|| { + msg!("ATA decompress requires wallet_owner from owner_index"); + CTokenError::DecompressDestinationMismatch + })?; + + // Wallet owner must be a signer + if !wallet_owner.is_signer() { + msg!("Wallet owner must be signer for ATA decompress"); + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + + // For ATA decompress, verify the destination is the correct ATA + // by deriving the ATA address from wallet_owner and comparing + let wallet_owner_bytes = wallet_owner.key(); + let mint_pubkey = ctoken.base.mint.to_bytes(); + let bump = ext_data.bump; + + // ATA seeds: [wallet_owner, program_id, mint, bump] + let bump_seed = [bump]; + let ata_seeds: [&[u8]; 4] = [ + wallet_owner_bytes.as_ref(), + crate::LIGHT_CPI_SIGNER.program_id.as_ref(), + mint_pubkey.as_ref(), + bump_seed.as_ref(), + ]; + + // Derive ATA address and verify it matches the destination + let derived_ata = pinocchio::pubkey::create_program_address( + &ata_seeds, + &crate::LIGHT_CPI_SIGNER.program_id, + ) + .map_err(|_| { + msg!("Failed to derive ATA address for decompress"); + ProgramError::InvalidSeeds + })?; + + // Verify derived ATA matches destination account pubkey + if !pubkey_eq(&derived_ata, destination_account.key()) { + msg!( + "Decompress ATA mismatch: derived {:?} != destination {:?}", + solana_pubkey::Pubkey::new_from_array(derived_ata), + solana_pubkey::Pubkey::new_from_array(*destination_account.key()) + ); + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + + // Verify the compressed account's owner (input_owner) matches the derived ATA + // This proves the compressed account belongs to this ATA + let input_owner_bytes = input_owner.to_bytes(); + if !pubkey_eq(&input_owner_bytes, &derived_ata) { + msg!( + "Decompress ATA: compressed owner {:?} != derived ATA {:?}", + solana_pubkey::Pubkey::new_from_array(input_owner_bytes), + solana_pubkey::Pubkey::new_from_array(derived_ata) + ); + return Err(CTokenError::DecompressDestinationMismatch.into()); + } } - // For ATA decompress, verify the destination is the correct ATA - // by deriving the ATA address from wallet_owner and comparing - let wallet_owner_bytes = wallet_owner.key(); - let mint_pubkey = ctoken.base.mint.to_bytes(); - let bump = ext_data.bump; - - // ATA seeds: [wallet_owner, program_id, mint, bump] - let bump_seed = [bump]; - let ata_seeds: [&[u8]; 4] = [ - wallet_owner_bytes.as_ref(), - crate::LIGHT_CPI_SIGNER.program_id.as_ref(), - mint_pubkey.as_ref(), - bump_seed.as_ref(), - ]; - - // Derive ATA address and verify it matches the destination - let derived_ata = pinocchio::pubkey::create_program_address( - &ata_seeds, - &crate::LIGHT_CPI_SIGNER.program_id, - ) - .map_err(|_| { - msg!("Failed to derive ATA address for decompress"); - ProgramError::InvalidSeeds - })?; - - // Verify derived ATA matches destination account pubkey - if !pubkey_eq(&derived_ata, destination_account.key()) { + if !pubkey_eq(&input_owner_bytes, destination_account.key()) { msg!( "Decompress ATA mismatch: derived {:?} != destination {:?}", solana_pubkey::Pubkey::new_from_array(derived_ata), @@ -79,18 +103,6 @@ fn validate_decompression_destination( return Err(CTokenError::DecompressDestinationMismatch.into()); } - // Verify the compressed account's owner (input_owner) matches the derived ATA - // This proves the compressed account belongs to this ATA - let input_owner_bytes = input_owner.to_bytes(); - if !pubkey_eq(&input_owner_bytes, &derived_ata) { - msg!( - "Decompress ATA: compressed owner {:?} != derived ATA {:?}", - solana_pubkey::Pubkey::new_from_array(input_owner_bytes), - solana_pubkey::Pubkey::new_from_array(derived_ata) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - // Also verify destination CToken owner matches wallet_owner // (destination should be wallet's ATA, owned by wallet) if !pubkey_eq(wallet_owner_bytes, &ctoken.base.owner.to_bytes()) { @@ -123,7 +135,7 @@ fn validate_decompression_destination( pub fn apply_decompress_extension_state( ctoken: &mut ZCTokenMut, destination_account: &AccountInfo, - decompress_inputs: Option, + decompress_inputs: Option, // TODO: pass in instruction data token data ) -> Result<(), ProgramError> { // If no decompress inputs, nothing to transfer let Some(inputs) = decompress_inputs else { @@ -174,7 +186,7 @@ pub fn apply_decompress_extension_state( // Add delegated_amount (only when we're setting the delegate) if delegated_amount > 0 { let current = ctoken.base.delegated_amount.get(); - ctoken.base.delegated_amount.set(current + delegated_amount); + ctoken.base.delegated_amount.set(current + delegated_amount); // TODO: use checked_add } } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs index bba5abb69b..944084c193 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs @@ -53,6 +53,7 @@ pub fn set_input_compressed_accounts<'a>( for ext in tlv { if let ZExtensionInstructionData::CompressedOnly(co) = ext { let idx = co.compression_index as usize; + // TODO check that it is not out of bounds // Check uniqueness - error if compression_index already used if compression_to_input[idx].is_some() { return Err(CTokenError::DuplicateCompressionIndex.into()); diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index eb8ceff21d..6d4e1905f4 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -161,7 +161,7 @@ fn process_compressible_top_up( /// 2: delegate (immutable) - the delegate authority /// 3: owner (signer, writable) - owner of source, payer for top-ups #[inline(always)] -pub fn process_ctoken_approve_checked( +pub fn process_ctoken_approve_checked( // TODO: remove this function accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index 822bc1b241..0d7bf8a05f 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -83,6 +83,7 @@ pub fn set_input_compressed_account<'a>( .find_map(|ext| { if let ZExtensionInstructionData::CompressedOnly(data) = ext { if data.is_ata != 0 { + // TODO: move ata derivation here, all signer checks must be in the input validation // Get wallet owner from owner_index packed_accounts.get(data.owner_index as usize) } else { From e92701bda5cee8bb881f521186ce75b5e38fb59a Mon Sep 17 00:00:00 2001 From: ananas Date: Fri, 9 Jan 2026 19:28:16 +0000 Subject: [PATCH 23/38] stash review fixes --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- .../instructions/mint_action/decompress_mint.rs | 1 - .../src/state/ctoken/ctoken_struct.rs | 14 +++++++++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71fde9c988..edbd0ea7a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5072,7 +5072,7 @@ dependencies = [ [[package]] name = "pinocchio-token-interface" version = "0.0.0" -source = "git+https://github.com/Lightprotocol/token?rev=0c55d18#0c55d185aaede4e83039ebfeb7a2caa000253450" +source = "git+https://github.com/Lightprotocol/token?rev=9ea04560a039d1a44f0411b5eaa7c0b79ed575ab#9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" dependencies = [ "pinocchio", "pinocchio-pubkey", @@ -5081,7 +5081,7 @@ dependencies = [ [[package]] name = "pinocchio-token-program" version = "0.1.0" -source = "git+https://github.com/Lightprotocol/token?rev=0c55d18#0c55d185aaede4e83039ebfeb7a2caa000253450" +source = "git+https://github.com/Lightprotocol/token?rev=9ea04560a039d1a44f0411b5eaa7c0b79ed575ab#9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" dependencies = [ "pinocchio", "pinocchio-log", diff --git a/Cargo.toml b/Cargo.toml index 06653ca36b..daef1112c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -232,7 +232,7 @@ groth16-solana = { version = "0.2.0" } bytemuck = { version = "1.19.0" } arrayvec = "0.7" tinyvec = "1.10.0" -pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="0c55d18" } +pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" } # Math and crypto num-bigint = "0.4.6" tabled = "0.20" diff --git a/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs b/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs index 2233caf44d..6940ff4ed8 100644 --- a/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs +++ b/program-libs/ctoken-interface/src/instructions/mint_action/decompress_mint.rs @@ -17,5 +17,4 @@ pub struct DecompressMintAction { /// Lamports allocated for future write operations (top-up per write). /// Must not exceed config.rent_config.max_top_up. pub write_top_up: u32, - // TODO: mint signer shouldnt need to sign for decompress_mint } diff --git a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs index f844d55b28..5a5cde1012 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs @@ -65,8 +65,17 @@ impl CToken { pub fn amount_from_slice(data: &[u8]) -> Result { const AMOUNT_OFFSET: usize = 64; // 32 (mint) + 32 (owner) - if data.len() < AMOUNT_OFFSET + 8 { - return Err(ZeroCopyError::Size); + check_token_account(&data)?; + + #[inline(always)] + fn check_token_account(bytes: &[u8]) -> Result<(), ZeroCopyError> { + if bytes.len() == 165 { + Ok(()) + } else if bytes.len() > 165 && bytes[165] == ACCOUNT_TYPE_TOKEN_ACCOUNT { + Ok(()) + } else { + Err(ZeroCopyError::InvalidConversion) + } } let amount_bytes = &data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8]; @@ -83,7 +92,6 @@ impl CToken { let data = account_info .try_borrow_data() .map_err(|_| ZeroCopyError::Size)?; - // TODO: check account is a ctoken account Self::amount_from_slice(&data) } From 396f059719618fb98828c1ac15355c31d88b38ae Mon Sep 17 00:00:00 2001 From: ananas Date: Fri, 9 Jan 2026 20:42:15 +0000 Subject: [PATCH 24/38] stash compress and close check refactor --- .../src/state/ctoken/zero_copy.rs | 12 + .../src/state/extensions/compressible.rs | 6 + .../compression/ctoken/compress_and_close.rs | 375 +++++++++--------- .../ctoken/compress_or_decompress_ctokens.rs | 10 +- .../compression/ctoken/decompress.rs | 122 ++---- .../program/src/ctoken/close/processor.rs | 92 +---- .../program/src/shared/token_input.rs | 25 +- 7 files changed, 270 insertions(+), 372 deletions(-) diff --git a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs index 2062d7067d..cdfce2d538 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs @@ -351,6 +351,12 @@ impl<'a> ZCTokenMut<'a> { // Getters on ZCTokenZeroCopyMeta (immutable) impl ZCTokenZeroCopyMeta<'_> { + /// Checks if account is uninitialized (state == 0) + #[inline(always)] + pub fn is_uninitialized(&self) -> bool { + self.state == 0 + } + /// Checks if account is initialized (state == 1) #[inline(always)] pub fn is_initialized(&self) -> bool { @@ -396,6 +402,12 @@ impl ZCTokenZeroCopyMeta<'_> { // Getters on ZCTokenZeroCopyMetaMut (mutable) impl ZCTokenZeroCopyMetaMut<'_> { + /// Checks if account is uninitialized (state == 0) + #[inline(always)] + pub fn is_uninitialized(&self) -> bool { + self.state == 0 + } + /// Checks if account is initialized (state == 1) #[inline(always)] pub fn is_initialized(&self) -> bool { diff --git a/program-libs/ctoken-interface/src/state/extensions/compressible.rs b/program-libs/ctoken-interface/src/state/extensions/compressible.rs index e98d61b3bc..52ef5979fd 100644 --- a/program-libs/ctoken-interface/src/state/extensions/compressible.rs +++ b/program-libs/ctoken-interface/src/state/extensions/compressible.rs @@ -98,4 +98,10 @@ impl ZCompressibleExtensionMut<'_> { } } } + + /// Returns whether this account is an ATA + #[inline(always)] + pub fn is_ata(&self) -> bool { + self.is_ata != 0 + } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index d382c17404..9c53d4e9e5 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -11,18 +11,21 @@ use light_ctoken_interface::{ CTokenError, }; use light_program_profiler::profile; +#[cfg(target_os = "solana")] +use pinocchio::sysvars::Sysvar; use pinocchio::{ account_info::AccountInfo, pubkey::{pubkey_eq, Pubkey}, }; + +#[cfg(target_os = "solana")] +use crate::shared::convert_program_error; use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; use crate::{ compressed_token::transfer2::accounts::Transfer2Accounts, - ctoken::close::{ - accounts::CloseTokenAccountAccounts, processor::validate_token_account_for_close_transfer2, - }, + ctoken::close::accounts::CloseTokenAccountAccounts, }; /// Process compress and close operation for a ctoken account. @@ -45,7 +48,7 @@ pub fn process_compress_and_close( compress_and_close_inputs.ok_or(ErrorCode::CompressAndCloseDestinationMissing)?; // Validate token account - only compressible accounts with compression_authority are allowed - let compress_to_pubkey = validate_token_account_for_close_transfer2( + let compress_to_pubkey = validate_token_account_for_close( &CloseTokenAccountAccounts { token_account: token_account_info, destination: close_inputs.destination, @@ -79,7 +82,20 @@ pub fn process_compress_and_close( Ok(()) } -/// Validate compressed token account for compress and close operation +/// Validate compressed token account for compress and close operation. +/// +/// Validations: +/// 1. Owner - output owner matches ctoken owner (or token account pubkey for ATA/compress_to_pubkey) +/// 2. Amount - compression_amount == output_amount == ctoken.amount +/// 3. Mint - output mint matches ctoken mint +/// 4. Version - must be ShaFlat +/// 5. Extension required - CompressedOnly extension required for compression_only or ATA accounts +/// 6. Without extension: account must not be frozen, must not have delegate +/// 7. With extension (via `validate_compressed_only_ext`): +/// 7a. Delegated amount must match +/// 7b. Delegate pubkey must match (if present) +/// 7c. Withheld fee must match +/// 7d. Frozen state must match fn validate_compressed_token_account( packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, compression_amount: u64, @@ -92,198 +108,110 @@ fn validate_compressed_token_account( let compression = ctoken .get_compressible_extension() .ok_or::(CTokenError::MissingCompressibleExtension.into())?; - let is_ata = compression.is_ata != 0; - // Owners should match if not compressing to pubkey - if compress_to_pubkey || is_ata { - // what about is ata? - // Owner should match token account pubkey if compressing to pubkey - if *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - != *token_account_pubkey - { - msg!( - "compress_to_pubkey: packed_accounts owner {:?} should match token_account_pubkey: {:?}", - solana_pubkey::Pubkey::new_from_array( - *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - ), - solana_pubkey::Pubkey::new_from_array(*token_account_pubkey) - ); - return Err(ErrorCode::CompressAndCloseInvalidOwner.into()); - } - } else if ctoken.owner.to_bytes() - != *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - { - msg!( - "*ctoken.owner {:?} packed_accounts owner: {:?}", - solana_pubkey::Pubkey::new_from_array(ctoken.owner.to_bytes()), - solana_pubkey::Pubkey::new_from_array( - *packed_accounts - .get_u8(compressed_token_account.owner, "CompressAndClose: owner")? - .key() - ) - ); + + // 1. Owner validation + let output_owner = packed_accounts + .get_u8(compressed_token_account.owner, "owner")? + .key(); + let expected_owner = if compress_to_pubkey || compression.is_ata() { + token_account_pubkey + } else { + &ctoken.owner.to_bytes() + }; + if output_owner != expected_owner { return Err(ErrorCode::CompressAndCloseInvalidOwner.into()); } - // Compression amount must match the output amount - if compression_amount != compressed_token_account.amount.get() { - msg!( - "compression_amount {} != compressed token account amount {}", - compression_amount, - compressed_token_account.amount.get() - ); + // 2. Amount validation + let output_amount = compressed_token_account.amount.get(); + if compression_amount != output_amount || ctoken.amount.get() != output_amount { return Err(ErrorCode::CompressAndCloseAmountMismatch.into()); } - // Token balance must match the compressed output amount - if ctoken.amount.get() != compressed_token_account.amount.get() { - msg!( - "output ctoken.amount {} != compressed token account amount {}", - ctoken.amount.get(), - compressed_token_account.amount.get() - ); - return Err(ErrorCode::CompressAndCloseBalanceMismatch.into()); - } - // Mint must match + // 3. Mint validation let output_mint = packed_accounts - .get_u8(compressed_token_account.mint, "CompressAndClose: mint")? + .get_u8(compressed_token_account.mint, "mint")? .key(); if *output_mint != ctoken.mint.to_bytes() { - msg!( - "mint mismatch: ctoken {:?} != output {:?}", - solana_pubkey::Pubkey::new_from_array(ctoken.mint.to_bytes()), - solana_pubkey::Pubkey::new_from_array(*output_mint) - ); return Err(ErrorCode::CompressAndCloseInvalidMint.into()); } - // Version should be ShaFlat + // 4. Version validation if compressed_token_account.version != TokenDataVersion::ShaFlat as u8 { return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); } - // Version should also match what's specified in the embedded compression info - let expected_version = compression.info.account_version; - let compression_only = compression.compression_only(); - - if compressed_token_account.version != expected_version { - return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); - } - let compression_only_extension = out_tlv.as_ref().and_then(|ext| { - ext.iter() - .find(|e| matches!(e, ZExtensionInstructionData::CompressedOnly(_))) + // 5. Extension required for compression_only or ATA accounts + let compression_only_ext = out_tlv.and_then(|tlv| { + tlv.iter().find_map(|e| match e { + ZExtensionInstructionData::CompressedOnly(ext) => Some(ext), + _ => None, + }) }); - - // CompressedOnly extension is required for: - // - compression_only accounts (cannot decompress to SPL) - // - ATA accounts (need is_ata flag for proper decompress authorization) - if (compression_only || is_ata) && compression_only_extension.is_none() { + if (compression.compression_only() || compression.is_ata()) && compression_only_ext.is_none() { return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); } - if let Some(ZExtensionInstructionData::CompressedOnly(compression_only_extension)) = - compression_only_extension - { - // Note: is_ata validation happens during decompress, not compress_and_close. - // During compress_and_close we just store the is_ata flag from the Compressible extension. - // The decompress instruction validates the ATA derivation using the stored is_ata and bump. - - // Delegated amounts must match - if u64::from(compression_only_extension.delegated_amount) != ctoken.delegated_amount.get() { - msg!( - "delegated_amount mismatch: ctoken {} != extension {}", - ctoken.delegated_amount.get(), - u64::from(compression_only_extension.delegated_amount) - ); - return Err(ErrorCode::CompressAndCloseDelegatedAmountMismatch.into()); + // 6. Without extension: must not be frozen, must not have delegate + let Some(ext) = compression_only_ext else { + if ctoken.is_frozen() { + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); } - // Delegate must be preserved for exact state restoration during decompress - if ctoken.delegate().is_some() || compression_only_extension.delegated_amount != 0 { - let delegate = ctoken - .delegate() - .ok_or(ErrorCode::CompressAndCloseInvalidDelegate)?; - if !compressed_token_account.has_delegate() { - msg!("ctoken has delegate but compressed token output does not"); - return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); - } - let token_data_delegate = packed_accounts.get_u8( - compressed_token_account.delegate, - "compressed_token_account delegate", - )?; - if !pubkey_eq(token_data_delegate.key(), &delegate.to_bytes()) { - msg!( - "delegate mismatch: ctoken {:?} != output {:?}", - solana_pubkey::Pubkey::new_from_array(delegate.to_bytes()), - solana_pubkey::Pubkey::new_from_array(*token_data_delegate.key()) - ); - return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); - } + if ctoken.delegate().is_some() || compressed_token_account.has_delegate() { + return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); } - // if ctoken has fee extension withheld amount must match - let ctoken_withheld_fee = ctoken.extensions.as_ref().and_then(|exts| { - exts.iter().find_map(|ext| { - if let ZExtensionStructMut::TransferFeeAccount(fee_ext) = ext { - Some(fee_ext.withheld_amount) - } else { - None - } - }) - }); + return Ok(()); + }; - if let Some(withheld_fee) = ctoken_withheld_fee { - if compression_only_extension.withheld_transfer_fee != withheld_fee { - msg!( - "withheld_transfer_fee mismatch: ctoken {} != extension {}", - withheld_fee, - u64::from(compression_only_extension.withheld_transfer_fee) - ); - return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); - } - } else if u64::from(compression_only_extension.withheld_transfer_fee) != 0 { - msg!( - "withheld_transfer_fee must be 0 when ctoken has no fee extension, got {}", - u64::from(compression_only_extension.withheld_transfer_fee) - ); - return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); - } + // 7. With extension: validate delegate, withheld_fee, frozen + validate_compressed_only_ext(packed_accounts, compressed_token_account, ctoken, ext) +} + +/// Validate CompressedOnly extension fields match ctoken state. +/// Called from validation 7 in `validate_compressed_token_account`. +fn validate_compressed_only_ext( + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + compressed_token_account: &ZMultiTokenTransferOutputData<'_>, + ctoken: &ZCTokenMut, + ext: &light_ctoken_interface::instructions::extensions::compressed_only::ZCompressedOnlyExtensionInstructionData, +) -> Result<(), ProgramError> { + // 7a. Delegated amount must match + let ext_delegated: u64 = ext.delegated_amount.into(); + if ext_delegated != ctoken.delegated_amount.get() { + return Err(ErrorCode::CompressAndCloseDelegatedAmountMismatch.into()); + } - // Frozen state must match between CToken and extension data - if ctoken.state != compression_only_extension.is_frozen { - msg!( - "is_frozen mismatch: ctoken {} != extension {}", - ctoken.state, - compression_only_extension.is_frozen - ); - return Err(ErrorCode::CompressAndCloseFrozenMismatch.into()); + // 7b. Delegate pubkey must match (if present) + if let Some(delegate) = ctoken.delegate() { + if !compressed_token_account.has_delegate() { + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); } - } else { - // Frozen accounts require CompressedOnly extension to preserve frozen state - // AccountState::Frozen = 2 in CToken - let ctoken_is_frozen = ctoken.state == 2; - if ctoken_is_frozen { - msg!("Frozen account requires CompressedOnly extension with is_frozen=true"); - return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); + let output_delegate = packed_accounts + .get_u8(compressed_token_account.delegate, "delegate")? + .key(); + if !pubkey_eq(output_delegate, &delegate.to_bytes()) { + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); } + } - // Source token account must not have a delegate - // Compressed tokens don't support delegation, so we reject accounts with delegates - if ctoken.delegate().is_some() { - msg!("Source token account has delegate, cannot compress and close"); - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } + // 7c. Withheld fee must match + let ctoken_fee = ctoken + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ZExtensionStructMut::TransferFeeAccount(f) => Some(f.withheld_amount.get()), + _ => None, + }) + }) + .unwrap_or(0); + if u64::from(ext.withheld_transfer_fee) != ctoken_fee { + return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); + } - // Delegate should be None - if compressed_token_account.has_delegate() { - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } - if compressed_token_account.delegate != 0 { - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } + // 7d. Frozen state must match + if ctoken.is_frozen() != ext.is_frozen() { + return Err(ErrorCode::CompressAndCloseFrozenMismatch.into()); } Ok(()) @@ -292,7 +220,7 @@ 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 @@ -318,32 +246,87 @@ pub fn close_for_compress_and_close( 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::ctoken::close::processor::close_token_account; - close_token_account(&CloseTokenAccountAccounts { - token_account: token_account_info, - destination, - authority, - rent_sponsor: Some(rent_sponsor), - })?; - } + 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::ctoken::close::processor::close_token_account; + close_token_account(&CloseTokenAccountAccounts { + token_account: token_account_info, + destination, + authority, + rent_sponsor: Some(rent_sponsor), + })?; } Ok(()) } + +/// Validates that a ctoken solana account is ready to be closed. +/// The rent authority can close the account. +#[profile] +pub fn validate_token_account_for_close( + accounts: &CloseTokenAccountAccounts, + ctoken: &ZCTokenMut<'_>, +) -> Result { + if accounts.token_account.key() == accounts.destination.key() { + return Err(ProgramError::InvalidAccountData); + } + + // Check for Compressible extension + let compressible = ctoken.get_compressible_extension(); + + // CompressAndClose requires Compressible extension + let compression = compressible.ok_or_else(|| { + msg!("compress and close requires compressible extension"); + ProgramError::InvalidAccountData + })?; + + // Validate rent_sponsor matches + let rent_sponsor = accounts + .rent_sponsor + .ok_or(ProgramError::NotEnoughAccountKeys)?; + if compression.info.rent_sponsor != *rent_sponsor.key() { + msg!("rent recipient mismatch"); + return Err(ProgramError::InvalidAccountData); + } + + if compression.info.compression_authority != *accounts.authority.key() { + msg!("compress and close requires compression authority"); + return Err(ProgramError::InvalidAccountData); + } + + #[cfg(target_os = "solana")] + { + let current_slot = pinocchio::sysvars::clock::Clock::get() + .map_err(convert_program_error)? + .slot; + let is_compressible = compression + .info + .is_compressible( + accounts.token_account.data_len() as u64, + current_slot, + accounts.token_account.lamports(), + ) + .map_err(|_| ProgramError::InvalidAccountData)?; + + if is_compressible.is_none() { + msg!("account not compressible"); + return Err(ProgramError::InvalidAccountData); + } + } + + // Return true if either compress_to_pubkey is set OR this is an ATA + // When true, the compressed account owner will be the token account pubkey + Ok(compression.info.compress_to_pubkey() || compression.is_ata()) +} diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index 36d9b2e996..aa7634b8bc 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -82,7 +82,7 @@ pub fn compress_or_decompress_ctokens( Ok(()) } ZCompressionMode::Decompress => { - apply_decompress_extension_state(&mut ctoken, token_account_info, decompress_inputs)?; + apply_decompress_extension_state(token_account_info, &mut ctoken, decompress_inputs)?; // Decompress: add to CToken account // Update the balance in the CToken solana account @@ -135,13 +135,13 @@ fn validate_ctoken( } // Reject uninitialized accounts (state == 0) - if !ctoken.is_initialized() { + if ctoken.base.is_uninitialized() { msg!("Account is uninitialized"); return Err(CTokenError::InvalidAccountState.into()); } - // Check if account is frozen (SPL Token-2022 compatibility) - // Frozen accounts cannot have their balance modified except for CompressAndClose - else if ctoken.is_frozen() && !mode.is_compress_and_close() { + + // Frozen accounts can only be modified via CompressAndClose + if ctoken.is_frozen() && !mode.is_compress_and_close() { msg!("Cannot modify frozen account"); return Err(ErrorCode::AccountFrozen.into()); } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs index 8d17a8bd58..53a53a341f 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -12,15 +12,19 @@ use spl_pod::solana_msg::msg; use super::inputs::DecompressCompressOnlyInputs; -/// Validates that the destination CToken matches the source account for ATA decompress. -/// For ATA decompress (is_ata=true), verifies the destination is the correct ATA. -/// For non-ATA decompress, just validates owner matches. +/// Validates that the destination CToken matches the source account for decompress. +/// ATA derivation and signer checks are done in input validation (token_input.rs). +/// +/// Checks: +/// - For ATA: destination account ADDRESS == input_owner (ATA pubkey from token data) +/// - For ATA: destination CToken owner field == wallet_owner +/// - For non-ATA: destination CToken owner field == input_owner (wallet pubkey) /// /// # Arguments -/// * `ctoken` - Destination CToken account -/// * `destination_account` - Destination account info -/// * `input_owner` - Compressed account owner (ATA pubkey for is_ata) -/// * `wallet_owner` - Wallet owner who signs (from owner_index, only for is_ata) +/// * `destination_account` - Destination CToken account info (for address check) +/// * `ctoken` - Destination CToken account data +/// * `input_owner` - Compressed account owner (ATA pubkey for is_ata, wallet for non-ATA) +/// * `wallet_owner` - Wallet owner (from owner_index, only for is_ata) /// * `ext_data` - CompressedOnly extension data #[inline(always)] fn validate_decompression_destination( @@ -30,92 +34,35 @@ fn validate_decompression_destination( wallet_owner: Option<&AccountInfo>, ext_data: &ZCompressedOnlyExtensionInstructionData, ) -> Result<(), ProgramError> { - // Owner must match (for non-ATA) or ATA must be correctly derived (for ATA) if ext_data.is_ata != 0 { - // Move to input validation and pass in instruction token data - { - // For ATA decompress, we need the wallet_owner - let wallet_owner = wallet_owner.ok_or_else(|| { - msg!("ATA decompress requires wallet_owner from owner_index"); - CTokenError::DecompressDestinationMismatch - })?; - - // Wallet owner must be a signer - if !wallet_owner.is_signer() { - msg!("Wallet owner must be signer for ATA decompress"); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - - // For ATA decompress, verify the destination is the correct ATA - // by deriving the ATA address from wallet_owner and comparing - let wallet_owner_bytes = wallet_owner.key(); - let mint_pubkey = ctoken.base.mint.to_bytes(); - let bump = ext_data.bump; - - // ATA seeds: [wallet_owner, program_id, mint, bump] - let bump_seed = [bump]; - let ata_seeds: [&[u8]; 4] = [ - wallet_owner_bytes.as_ref(), - crate::LIGHT_CPI_SIGNER.program_id.as_ref(), - mint_pubkey.as_ref(), - bump_seed.as_ref(), - ]; - - // Derive ATA address and verify it matches the destination - let derived_ata = pinocchio::pubkey::create_program_address( - &ata_seeds, - &crate::LIGHT_CPI_SIGNER.program_id, - ) - .map_err(|_| { - msg!("Failed to derive ATA address for decompress"); - ProgramError::InvalidSeeds - })?; - - // Verify derived ATA matches destination account pubkey - if !pubkey_eq(&derived_ata, destination_account.key()) { - msg!( - "Decompress ATA mismatch: derived {:?} != destination {:?}", - solana_pubkey::Pubkey::new_from_array(derived_ata), - solana_pubkey::Pubkey::new_from_array(*destination_account.key()) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - - // Verify the compressed account's owner (input_owner) matches the derived ATA - // This proves the compressed account belongs to this ATA - let input_owner_bytes = input_owner.to_bytes(); - if !pubkey_eq(&input_owner_bytes, &derived_ata) { - msg!( - "Decompress ATA: compressed owner {:?} != derived ATA {:?}", - solana_pubkey::Pubkey::new_from_array(input_owner_bytes), - solana_pubkey::Pubkey::new_from_array(derived_ata) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - } - - if !pubkey_eq(&input_owner_bytes, destination_account.key()) { + // For ATA decompress: + // 1. Verify destination account ADDRESS == input_owner (ATA pubkey from token data) + if !pubkey_eq(destination_account.key(), &input_owner.to_bytes()) { msg!( - "Decompress ATA mismatch: derived {:?} != destination {:?}", - solana_pubkey::Pubkey::new_from_array(derived_ata), - solana_pubkey::Pubkey::new_from_array(*destination_account.key()) + "Decompress ATA: destination address {:?} != token data owner {:?}", + solana_pubkey::Pubkey::new_from_array(*destination_account.key()), + solana_pubkey::Pubkey::new_from_array(input_owner.to_bytes()) ); return Err(CTokenError::DecompressDestinationMismatch.into()); } - // Also verify destination CToken owner matches wallet_owner - // (destination should be wallet's ATA, owned by wallet) - if !pubkey_eq(wallet_owner_bytes, &ctoken.base.owner.to_bytes()) { + // 2. Verify CToken owner field == wallet_owner + let wallet_owner = wallet_owner.ok_or_else(|| { + msg!("ATA decompress requires wallet_owner from owner_index"); + CTokenError::DecompressDestinationMismatch + })?; + + if !pubkey_eq(wallet_owner.key(), &ctoken.base.owner.to_bytes()) { msg!( - "Decompress ATA: wallet owner {:?} != destination owner {:?}", - solana_pubkey::Pubkey::new_from_array(*wallet_owner_bytes), + "Decompress ATA: wallet owner {:?} != destination owner field {:?}", + solana_pubkey::Pubkey::new_from_array(*wallet_owner.key()), solana_pubkey::Pubkey::new_from_array(ctoken.base.owner.to_bytes()) ); return Err(CTokenError::DecompressDestinationMismatch.into()); } } else { - // For non-ATA decompress, owner must match - if ctoken.base.owner.to_bytes() != input_owner.to_bytes() { + // For non-ATA decompress, CToken owner field must match input_owner (wallet pubkey) + if !pubkey_eq(&ctoken.base.owner.to_bytes(), &input_owner.to_bytes()) { msg!("Decompress destination owner mismatch"); return Err(CTokenError::DecompressDestinationMismatch.into()); } @@ -128,14 +75,13 @@ fn validate_decompression_destination( /// This transfers delegate, delegated_amount, and withheld_transfer_fee from /// the compressed account's CompressedOnly extension to the CToken account. /// -/// For ATA decompress with is_ata=true, validates the destination matches the -/// derived ATA address. Existing delegate/amount on destination is preserved -/// and added to rather than overwritten. +/// ATA derivation validation is done in input validation (token_input.rs). +/// This validates destination matches token data owner and applies extension state. #[inline(always)] pub fn apply_decompress_extension_state( - ctoken: &mut ZCTokenMut, destination_account: &AccountInfo, - decompress_inputs: Option, // TODO: pass in instruction data token data + ctoken: &mut ZCTokenMut, + decompress_inputs: Option, ) -> Result<(), ProgramError> { // If no decompress inputs, nothing to transfer let Some(inputs) = decompress_inputs else { @@ -156,7 +102,7 @@ pub fn apply_decompress_extension_state( return Ok(()); }; - // Validate destination matches expected (ATA derivation or owner match) + // Validate destination matches token data owner validate_decompression_destination( ctoken, destination_account, @@ -212,7 +158,7 @@ pub fn apply_decompress_extension_state( } // Handle is_frozen - restore frozen state from compressed token - if ext_data.is_frozen != 0 { + if ext_data.is_frozen() { ctoken.base.set_frozen(); } diff --git a/programs/compressed-token/program/src/ctoken/close/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs index d3c0e2ea8a..efafb9acc9 100644 --- a/programs/compressed-token/program/src/ctoken/close/processor.rs +++ b/programs/compressed-token/program/src/ctoken/close/processor.rs @@ -31,7 +31,7 @@ pub fn process_close_token_account( let token_account_data = &mut AccountInfoTrait::try_borrow_mut_data(accounts.token_account)?; let (ctoken, _) = CToken::zero_copy_at_mut_checked(token_account_data)?; - validate_token_account_close_instruction(&accounts, &ctoken)?; + validate_token_account_close(&accounts, &ctoken)?; } close_token_account(&accounts)?; Ok(()) @@ -40,95 +40,25 @@ pub fn process_close_token_account( /// Validates that a ctoken solana account is ready to be closed. /// Only the owner or close_authority can close the account. #[profile] -pub fn validate_token_account_close_instruction( +fn validate_token_account_close( accounts: &CloseTokenAccountAccounts, ctoken: &ZCTokenMut<'_>, ) -> Result<(), ProgramError> { - validate_token_account::(accounts, ctoken)?; - Ok(()) -} - -/// Validates that a ctoken solana account is ready to be closed. -/// The rent authority can close the account. -#[profile] -pub fn validate_token_account_for_close_transfer2( - accounts: &CloseTokenAccountAccounts, - ctoken: &ZCTokenMut<'_>, -) -> Result { - validate_token_account::(accounts, ctoken) -} - -#[inline(always)] -fn validate_token_account( - accounts: &CloseTokenAccountAccounts, - ctoken: &ZCTokenMut<'_>, -) -> Result { if accounts.token_account.key() == accounts.destination.key() { return Err(ProgramError::InvalidAccountData); } - // For compress and close we compress the balance and close. - if !COMPRESS_AND_CLOSE { - // Check that the account has zero balance - if u64::from(ctoken.amount) != 0 { - return Err(ErrorCode::NonNativeHasBalance.into()); - } - // Note: Non-zero transfer fees are not yet supported. If fees != 0 support is added: - // - Check TransferFeeAccount.withheld_amount == 0 before allowing close - // - Implement harvest_withheld_fees instruction to extract fees first - // - T22 blocks close when withheld_amount > 0 to prevent fee loss + // Check that the account has zero balance + if u64::from(ctoken.amount) != 0 { + return Err(ErrorCode::NonNativeHasBalance.into()); } + // Note: Non-zero transfer fees are not yet supported. If fees != 0 support is added: + // - Check TransferFeeAccount.withheld_amount == 0 before allowing close + // - Implement harvest_withheld_fees instruction to extract fees first + // - T22 blocks close when withheld_amount > 0 to prevent fee loss + // Check for Compressible extension let compressible = ctoken.get_compressible_extension(); - // TODO: extract into separate function and remove const generic - if COMPRESS_AND_CLOSE { - if accounts.token_account.key() == accounts.destination.key() { - return Err(ProgramError::InvalidAccountData); - } - // CompressAndClose requires Compressible extension - let compression = compressible.ok_or_else(|| { - msg!("compress and close requires compressible extension"); - ProgramError::InvalidAccountData - })?; - - // Validate rent_sponsor matches - let rent_sponsor = accounts - .rent_sponsor - .ok_or(ProgramError::NotEnoughAccountKeys)?; - if compression.info.rent_sponsor != *rent_sponsor.key() { - msg!("rent recipient mismatch"); - return Err(ProgramError::InvalidAccountData); - } - - if compression.info.compression_authority != *accounts.authority.key() { - msg!("compress and close requires compression authority"); - return Err(ProgramError::InvalidAccountData); - } - - #[cfg(target_os = "solana")] - { - let current_slot = pinocchio::sysvars::clock::Clock::get() - .map_err(convert_program_error)? - .slot; - let is_compressible = compression - .info - .is_compressible( - accounts.token_account.data_len() as u64, - current_slot, - accounts.token_account.lamports(), - ) - .map_err(|_| ProgramError::InvalidAccountData)?; - - if is_compressible.is_none() { - msg!("account not compressible"); - return Err(ProgramError::InvalidAccountData); - } - } - - // Return true if either compress_to_pubkey is set OR this is an ATA - // When true, the compressed account owner will be the token account pubkey - return Ok(compression.info.compress_to_pubkey() || compression.is_ata != 0); - } // For regular close: validate rent_sponsor if it has a compressible extension if let Some(compression) = compressible { @@ -174,7 +104,7 @@ fn validate_token_account( return Err(ErrorCode::OwnerMismatch.into()); } } - Ok(false) + Ok(()) } pub fn close_token_account(accounts: &CloseTokenAccountAccounts<'_>) -> Result<(), ProgramError> { diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index 0d7bf8a05f..96ae4bbbd3 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -78,14 +78,35 @@ pub fn set_input_compressed_account<'a>( // For ATA decompress (is_ata=true), verify the wallet owner from owner_index instead // of the compressed account owner (which is the ATA pubkey that can't sign). + // Also verify that owner_account (the ATA) matches the derived ATA from wallet_owner + mint + bump. let signer_account = if let Some(exts) = tlv_data { exts.iter() .find_map(|ext| { if let ZExtensionInstructionData::CompressedOnly(data) = ext { if data.is_ata != 0 { - // TODO: move ata derivation here, all signer checks must be in the input validation // Get wallet owner from owner_index - packed_accounts.get(data.owner_index as usize) + let wallet_owner = packed_accounts.get(data.owner_index as usize)?; + + // Derive ATA and verify owner_account matches + let bump_seed = [data.bump]; + let ata_seeds: [&[u8]; 4] = [ + wallet_owner.key().as_ref(), + crate::LIGHT_CPI_SIGNER.program_id.as_ref(), + mint_account.key().as_ref(), + bump_seed.as_ref(), + ]; + let derived_ata = pinocchio::pubkey::create_program_address( + &ata_seeds, + &crate::LIGHT_CPI_SIGNER.program_id, + ) + .ok()?; + + // owner_account.key() IS the ATA - verify it matches derived + if !pinocchio::pubkey::pubkey_eq(owner_account.key(), &derived_ata) { + return None; // Will cause error below + } + + Some(wallet_owner) } else { None } From d9f138967ce7a2cd142815a5dbfca3aee077329a Mon Sep 17 00:00:00 2001 From: ananas Date: Fri, 9 Jan 2026 21:49:15 +0000 Subject: [PATCH 25/38] format and refactor --- .../tests/unit/mint-action-layout.test.ts | 16 +- .../src/instructions/extensions/mod.rs | 15 +- .../src/state/ctoken/ctoken_struct.rs | 7 +- .../compression/ctoken/compress_and_close.rs | 5 +- .../ctoken/compress_or_decompress_ctokens.rs | 7 +- .../compression/ctoken/decompress.rs | 241 +++++++++--------- .../transfer2/compression/ctoken/inputs.rs | 49 +--- .../transfer2/compression/mod.rs | 1 - .../program/src/ctoken/approve_revoke.rs | 3 +- .../src/extensions/check_mint_extensions.rs | 2 +- .../program/src/shared/compressible_top_up.rs | 3 +- 11 files changed, 162 insertions(+), 187 deletions(-) diff --git a/js/compressed-token/tests/unit/mint-action-layout.test.ts b/js/compressed-token/tests/unit/mint-action-layout.test.ts index 865397f46f..2e124393eb 100644 --- a/js/compressed-token/tests/unit/mint-action-layout.test.ts +++ b/js/compressed-token/tests/unit/mint-action-layout.test.ts @@ -46,7 +46,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, - compressedAddress: Array.from(new Uint8Array(32).fill(1)), + compressedAddress: Array.from( + new Uint8Array(32).fill(1), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -98,7 +100,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, - compressedAddress: Array.from(new Uint8Array(32).fill(0)), + compressedAddress: Array.from( + new Uint8Array(32).fill(0), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, @@ -141,7 +145,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, - compressedAddress: Array.from(new Uint8Array(32).fill(5)), + compressedAddress: Array.from( + new Uint8Array(32).fill(5), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: freezeAuthority.publicKey, @@ -185,7 +191,9 @@ describe('MintActionCompressedInstructionData Layout', () => { version: TokenDataVersion.ShaFlat, cmintDecompressed: false, mint: mintSigner.publicKey, - compressedAddress: Array.from(new Uint8Array(32).fill(6)), + compressedAddress: Array.from( + new Uint8Array(32).fill(6), + ), }, mintAuthority: mintAuthority.publicKey, freezeAuthority: null, diff --git a/program-libs/ctoken-interface/src/instructions/extensions/mod.rs b/program-libs/ctoken-interface/src/instructions/extensions/mod.rs index dc4a5b8936..69e77eae1f 100644 --- a/program-libs/ctoken-interface/src/instructions/extensions/mod.rs +++ b/program-libs/ctoken-interface/src/instructions/extensions/mod.rs @@ -1,7 +1,9 @@ pub mod compressed_only; pub mod compressible; pub mod token_metadata; -pub use compressed_only::CompressedOnlyExtensionInstructionData; +pub use compressed_only::{ + CompressedOnlyExtensionInstructionData, ZCompressedOnlyExtensionInstructionData, +}; pub use compressible::{CompressToPubkey, CompressibleExtensionInstructionData}; use light_compressible::compression_info::CompressionInfo; use light_zero_copy::ZeroCopy; @@ -51,3 +53,14 @@ pub enum ExtensionInstructionData { /// Position 32 matches ExtensionStruct::Compressible Compressible(CompressionInfo), } + +/// Find the CompressedOnly extension from a TLV slice. +#[inline(always)] +pub fn find_compressed_only<'a>( + tlv: &'a [ZExtensionInstructionData<'a>], +) -> Option<&'a ZCompressedOnlyExtensionInstructionData<'a>> { + tlv.iter().find_map(|ext| match ext { + ZExtensionInstructionData::CompressedOnly(data) => Some(data), + _ => None, + }) +} diff --git a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs index 5a5cde1012..20e1e5e0ea 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs @@ -65,13 +65,12 @@ impl CToken { pub fn amount_from_slice(data: &[u8]) -> Result { const AMOUNT_OFFSET: usize = 64; // 32 (mint) + 32 (owner) - check_token_account(&data)?; + check_token_account(data)?; #[inline(always)] fn check_token_account(bytes: &[u8]) -> Result<(), ZeroCopyError> { - if bytes.len() == 165 { - Ok(()) - } else if bytes.len() > 165 && bytes[165] == ACCOUNT_TYPE_TOKEN_ACCOUNT { + if bytes.len() == 165 || (bytes.len() > 165 && bytes[165] == ACCOUNT_TYPE_TOKEN_ACCOUNT) + { Ok(()) } else { Err(ZeroCopyError::InvalidConversion) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 9c53d4e9e5..167aadda51 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -17,12 +17,11 @@ use pinocchio::{ account_info::AccountInfo, pubkey::{pubkey_eq, Pubkey}, }; - -#[cfg(target_os = "solana")] -use crate::shared::convert_program_error; use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; +#[cfg(target_os = "solana")] +use crate::shared::convert_program_error; use crate::{ compressed_token::transfer2::accounts::Transfer2Accounts, ctoken::close::accounts::CloseTokenAccountAccounts, diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index aa7634b8bc..4a1b24d5e0 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -82,7 +82,12 @@ pub fn compress_or_decompress_ctokens( Ok(()) } ZCompressionMode::Decompress => { - apply_decompress_extension_state(token_account_info, &mut ctoken, decompress_inputs)?; + apply_decompress_extension_state( + token_account_info, + &mut ctoken, + decompress_inputs, + packed_accounts, + )?; // Decompress: add to CToken account // Update the balance in the CToken solana account diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs index 53a53a341f..21fb0c6109 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -1,9 +1,8 @@ use anchor_lang::prelude::ProgramError; +use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_compressed_account::Pubkey; use light_ctoken_interface::{ - instructions::extensions::{ - compressed_only::ZCompressedOnlyExtensionInstructionData, ZExtensionInstructionData, - }, + instructions::extensions::{find_compressed_only, ZCompressedOnlyExtensionInstructionData}, state::{ZCTokenMut, ZExtensionStructMut}, CTokenError, }; @@ -12,155 +11,143 @@ use spl_pod::solana_msg::msg; use super::inputs::DecompressCompressOnlyInputs; -/// Validates that the destination CToken matches the source account for decompress. -/// ATA derivation and signer checks are done in input validation (token_input.rs). -/// -/// Checks: -/// - For ATA: destination account ADDRESS == input_owner (ATA pubkey from token data) -/// - For ATA: destination CToken owner field == wallet_owner -/// - For non-ATA: destination CToken owner field == input_owner (wallet pubkey) +/// Apply extension state from compressed account to CToken during decompress. +#[inline(always)] +pub fn apply_decompress_extension_state( + destination_account: &AccountInfo, + ctoken: &mut ZCTokenMut, + decompress_inputs: Option, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, +) -> Result<(), ProgramError> { + let Some(inputs) = decompress_inputs else { + return Ok(()); + }; + + let Some(ext_data) = find_compressed_only(inputs.tlv) else { + return Ok(()); + }; + + // === VALIDATE destination ownership === + let input_owner = packed_accounts.get_u8(inputs.input_token_data.owner, "input owner")?; + validate_destination( + ctoken, + destination_account, + input_owner.key(), + ext_data, + packed_accounts, + )?; + + // === APPLY delegate state === + apply_delegate(ctoken, ext_data, &inputs, packed_accounts)?; + + // === APPLY withheld fee === + apply_withheld_fee(ctoken, ext_data)?; + + // === APPLY frozen state === + if ext_data.is_frozen() { + ctoken.base.set_frozen(); + } + + Ok(()) +} + +/// Validate destination matches the source account for decompress. /// -/// # Arguments -/// * `destination_account` - Destination CToken account info (for address check) -/// * `ctoken` - Destination CToken account data -/// * `input_owner` - Compressed account owner (ATA pubkey for is_ata, wallet for non-ATA) -/// * `wallet_owner` - Wallet owner (from owner_index, only for is_ata) -/// * `ext_data` - CompressedOnly extension data +/// For non-ATA: CToken owner == input_owner (wallet pubkey) +/// For ATA: destination address == input_owner (ATA pubkey), and CToken owner == wallet_owner #[inline(always)] -fn validate_decompression_destination( +fn validate_destination( ctoken: &ZCTokenMut, - destination_account: &AccountInfo, - input_owner: &Pubkey, - wallet_owner: Option<&AccountInfo>, + destination: &AccountInfo, + input_owner_key: &[u8; 32], ext_data: &ZCompressedOnlyExtensionInstructionData, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, ) -> Result<(), ProgramError> { - if ext_data.is_ata != 0 { - // For ATA decompress: - // 1. Verify destination account ADDRESS == input_owner (ATA pubkey from token data) - if !pubkey_eq(destination_account.key(), &input_owner.to_bytes()) { - msg!( - "Decompress ATA: destination address {:?} != token data owner {:?}", - solana_pubkey::Pubkey::new_from_array(*destination_account.key()), - solana_pubkey::Pubkey::new_from_array(input_owner.to_bytes()) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - - // 2. Verify CToken owner field == wallet_owner - let wallet_owner = wallet_owner.ok_or_else(|| { - msg!("ATA decompress requires wallet_owner from owner_index"); - CTokenError::DecompressDestinationMismatch - })?; - - if !pubkey_eq(wallet_owner.key(), &ctoken.base.owner.to_bytes()) { - msg!( - "Decompress ATA: wallet owner {:?} != destination owner field {:?}", - solana_pubkey::Pubkey::new_from_array(*wallet_owner.key()), - solana_pubkey::Pubkey::new_from_array(ctoken.base.owner.to_bytes()) - ); - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - } else { - // For non-ATA decompress, CToken owner field must match input_owner (wallet pubkey) - if !pubkey_eq(&ctoken.base.owner.to_bytes(), &input_owner.to_bytes()) { + // Non-ATA: simple owner match (handle simpler case first) + if !ext_data.is_ata() { + if !pubkey_eq(ctoken.base.owner.array_ref(), input_owner_key) { msg!("Decompress destination owner mismatch"); return Err(CTokenError::DecompressDestinationMismatch.into()); } + return Ok(()); + } + + // ATA: destination address == input_owner (ATA pubkey) + if !pubkey_eq(destination.key(), input_owner_key) { + msg!("Decompress ATA: destination address mismatch"); + return Err(CTokenError::DecompressDestinationMismatch.into()); } + // ATA: wallet owner == CToken owner field + let wallet = packed_accounts.get_u8(ext_data.owner_index, "wallet owner")?; + if !pubkey_eq(wallet.key(), ctoken.base.owner.array_ref()) { + msg!("Decompress ATA: wallet owner mismatch"); + return Err(CTokenError::DecompressDestinationMismatch.into()); + } Ok(()) } -/// Apply extension state from the input compressed account during decompress. -/// This transfers delegate, delegated_amount, and withheld_transfer_fee from -/// the compressed account's CompressedOnly extension to the CToken account. -/// -/// ATA derivation validation is done in input validation (token_input.rs). -/// This validates destination matches token data owner and applies extension state. +/// Apply delegate state. Resolves delegate only when needed (inside the check). #[inline(always)] -pub fn apply_decompress_extension_state( - destination_account: &AccountInfo, +fn apply_delegate( ctoken: &mut ZCTokenMut, - decompress_inputs: Option, + ext_data: &ZCompressedOnlyExtensionInstructionData, + inputs: &DecompressCompressOnlyInputs, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, ) -> Result<(), ProgramError> { - // If no decompress inputs, nothing to transfer - let Some(inputs) = decompress_inputs else { + // Skip if destination already has delegate + if ctoken.delegate().is_some() { return Ok(()); - }; + } - // Extract CompressedOnly extension data from input TLV - let compressed_only_data = inputs.tlv.iter().find_map(|ext| { - if let ZExtensionInstructionData::CompressedOnly(data) = ext { - Some(data) - } else { - None - } - }); + let delegated_amount: u64 = ext_data.delegated_amount.into(); - // If no CompressedOnly extension, nothing to transfer - let Some(ext_data) = compressed_only_data else { - return Ok(()); + // Resolve delegate only when needed + let input_delegate = if inputs.input_token_data.has_delegate() { + Some(packed_accounts.get_u8(inputs.input_token_data.delegate, "delegate")?) + } else { + None }; - // Validate destination matches token data owner - validate_decompression_destination( - ctoken, - destination_account, - &Pubkey::from(*inputs.owner.key()), - inputs.wallet_owner, - ext_data, - )?; - - let delegated_amount: u64 = ext_data.delegated_amount.into(); - let withheld_transfer_fee: u64 = ext_data.withheld_transfer_fee.into(); - - // Handle delegate and delegated_amount - // If destination already has delegate, skip delegate AND delegated_amount restoration (preserve existing) - if delegated_amount > 0 || inputs.delegate.is_some() { - let input_delegate_pubkey = inputs.delegate.map(|acc| Pubkey::from(*acc.key())); - - // Only set delegate and delegated_amount if destination doesn't already have one - if ctoken.delegate().is_none() { - if let Some(input_del) = input_delegate_pubkey { - ctoken.base.set_delegate(Some(input_del))?; - } else if delegated_amount > 0 { - // Has delegated_amount but no delegate pubkey - invalid state - msg!("Decompress: delegated_amount > 0 but no delegate pubkey provided"); - return Err(CTokenError::DecompressDelegatedAmountWithoutDelegate.into()); - } - - // Add delegated_amount (only when we're setting the delegate) - if delegated_amount > 0 { - let current = ctoken.base.delegated_amount.get(); - ctoken.base.delegated_amount.set(current + delegated_amount); // TODO: use checked_add - } + if let Some(delegate_acc) = input_delegate { + ctoken + .base + .set_delegate(Some(Pubkey::from(*delegate_acc.key())))?; + if delegated_amount > 0 { + let current = ctoken.base.delegated_amount.get(); + ctoken.base.delegated_amount.set(current + delegated_amount); } + } else if delegated_amount > 0 { + msg!("Decompress: delegated_amount > 0 but no delegate"); + return Err(CTokenError::DecompressDelegatedAmountWithoutDelegate.into()); } - // Handle withheld_transfer_fee (always add, not overwrite) - // Defensive: ensures compress/decompress always works for ctoken accounts. - // It should not be possible to set withheld_transfer_fee to non-zero. - if withheld_transfer_fee > 0 { - let mut fee_applied = false; - if let Some(extensions) = ctoken.extensions.as_deref_mut() { - for extension in extensions.iter_mut() { - if let ZExtensionStructMut::TransferFeeAccount(ref mut fee_ext) = extension { - fee_ext.add_withheld_amount(withheld_transfer_fee)?; - fee_applied = true; - break; - } - } - } - if !fee_applied { - msg!("Decompress: withheld_transfer_fee > 0 but no TransferFeeAccount extension found"); - return Err(CTokenError::DecompressWithheldFeeWithoutExtension.into()); - } - } + Ok(()) +} - // Handle is_frozen - restore frozen state from compressed token - if ext_data.is_frozen() { - ctoken.base.set_frozen(); +/// Apply withheld transfer fee to TransferFeeAccount extension. +#[inline(always)] +fn apply_withheld_fee( + ctoken: &mut ZCTokenMut, + ext_data: &ZCompressedOnlyExtensionInstructionData, +) -> Result<(), ProgramError> { + let fee: u64 = ext_data.withheld_transfer_fee.into(); + if fee == 0 { + return Ok(()); } - Ok(()) + let fee_ext = ctoken.extensions.as_deref_mut().and_then(|exts| { + exts.iter_mut().find_map(|ext| match ext { + ZExtensionStructMut::TransferFeeAccount(f) => Some(f), + _ => None, + }) + }); + + match fee_ext { + Some(f) => Ok(f.add_withheld_amount(fee)?), + None => { + msg!("Decompress: withheld fee but no TransferFeeAccount extension"); + Err(CTokenError::DecompressWithheldFeeWithoutExtension.into()) + } + } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs index 8b2006220e..db32dee1d0 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs @@ -4,7 +4,7 @@ use light_ctoken_interface::instructions::{ extensions::ZExtensionInstructionData, transfer2::{ ZCompressedTokenInstructionDataTransfer2, ZCompression, ZCompressionMode, - ZMultiTokenTransferOutputData, + ZMultiInputTokenDataWithContext, ZMultiTokenTransferOutputData, }, }; use pinocchio::{account_info::AccountInfo, pubkey::Pubkey}; @@ -17,20 +17,14 @@ use crate::{extensions::MintExtensionChecks, MAX_COMPRESSIONS}; pub struct DecompressCompressOnlyInputs<'a> { /// Input TLV for decompress operations (from the input compressed account being consumed). pub tlv: &'a [ZExtensionInstructionData<'a>], - /// Delegate pubkey from input compressed account (for decompress extension state transfer). - pub delegate: Option<&'a AccountInfo>, - /// Owner pubkey from input compressed account (for decompress destination validation). - /// For is_ata=true, this is the ATA pubkey (not the wallet owner). - pub owner: &'a AccountInfo, - /// Wallet owner for ATA decompress (from owner_index in CompressedOnly extension). - /// Only set when is_ata=true. Used for ATA derivation validation. - pub wallet_owner: Option<&'a AccountInfo>, + /// The input compressed token data being consumed. + pub input_token_data: &'a ZMultiInputTokenDataWithContext<'a>, } impl<'a> DecompressCompressOnlyInputs<'a> { /// Extract decompress inputs for CompressedOnly extension state transfer. /// - /// Extracts TLV, delegate, and owner from the input compressed account for decompress + /// Extracts TLV and input_token_data from the input compressed account for decompress /// operations. Also validates compression-input consistency (mode and mint match). #[inline(always)] pub fn try_extract( @@ -38,7 +32,6 @@ impl<'a> DecompressCompressOnlyInputs<'a> { compression_index: usize, compression_to_input: &[Option; MAX_COMPRESSIONS], inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, - packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, ) -> Result, ProgramError> { let Some(input_idx) = compression_to_input[compression_index] else { return Ok(None); @@ -55,11 +48,11 @@ impl<'a> DecompressCompressOnlyInputs<'a> { } // Validate mint matches between compression and input - let input_data = inputs + let input_token_data = inputs .in_token_data .get(idx) .ok_or(ProgramError::InvalidInstructionData)?; - if compression.mint != input_data.mint { + if compression.mint != input_token_data.mint { msg!( "Mint mismatch between compression and input at index {}", compression_index @@ -75,37 +68,9 @@ impl<'a> DecompressCompressOnlyInputs<'a> { .map(|v| v.as_slice()) .unwrap_or(&[]); - // Get delegate (optional, only if input has delegate) - let delegate = if input_data.has_delegate() { - Some(packed_accounts.get_u8(input_data.delegate, "input delegate")?) - } else { - None - }; - - // Get owner (required for DecompressCompressOnlyInputs) - let owner = packed_accounts.get_u8(input_data.owner, "input owner")?; - - // For is_ata decompress, extract wallet_owner from owner_index in CompressedOnly extension - let wallet_owner = tlv.iter().find_map(|ext| { - if let ZExtensionInstructionData::CompressedOnly(data) = ext { - if data.is_ata != 0 { - // Get wallet owner from owner_index - packed_accounts - .get_u8(data.owner_index, "wallet owner") - .ok() - } else { - None - } - } else { - None - } - }); - Ok(Some(DecompressCompressOnlyInputs { tlv, - delegate, - owner, - wallet_owner, + input_token_data, })) } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index 10175e3fbf..92699f2702 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -89,7 +89,6 @@ pub fn process_token_compression<'a>( compression_index, compression_to_input, inputs, - packed_accounts, )?; ctoken::process_ctoken_compressions( diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index 6d4e1905f4..07a74f990a 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -161,7 +161,8 @@ fn process_compressible_top_up( /// 2: delegate (immutable) - the delegate authority /// 3: owner (signer, writable) - owner of source, payer for top-ups #[inline(always)] -pub fn process_ctoken_approve_checked( // TODO: remove this function +pub fn process_ctoken_approve_checked( + // TODO: remove this function accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { diff --git a/programs/compressed-token/program/src/extensions/check_mint_extensions.rs b/programs/compressed-token/program/src/extensions/check_mint_extensions.rs index 9bf740e0e5..15c2e6cd24 100644 --- a/programs/compressed-token/program/src/extensions/check_mint_extensions.rs +++ b/programs/compressed-token/program/src/extensions/check_mint_extensions.rs @@ -55,7 +55,7 @@ pub fn parse_mint_extensions( ) -> Result { // Only Token-2022 mints can have extensions if !mint_account.is_owned_by(&SPL_TOKEN_2022_ID) { - return Err(ProgramError::InvalidAccountOwner); + return Ok(MintExtensionChecks::default()); } let mint_data = AccountInfoTrait::try_borrow_data(mint_account)?; diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index a37dfdfb9b..28699f1e43 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -10,12 +10,11 @@ use pinocchio::{ sysvars::{clock::Clock, rent::Rent, Sysvar}, }; -use crate::LIGHT_CPI_SIGNER; - use super::{ convert_program_error, transfer_lamports::{multi_transfer_lamports, Transfer}, }; +use crate::LIGHT_CPI_SIGNER; /// Calculate and execute top-up transfers for compressible CMint and CToken accounts. /// CMint always has compression info. CToken requires Compressible extension or errors. From 4e637df80e96f633de3cab030e10e4c218a2b398 Mon Sep 17 00:00:00 2001 From: ananas Date: Fri, 9 Jan 2026 22:09:57 +0000 Subject: [PATCH 26/38] removed approve checked, refactored check extensions --- .../tests/ctoken/approve_revoke.rs | 167 ------------------ .../tests/ctoken/spl_instruction_compat.rs | 65 +------ .../transfer2/check_extensions.rs | 49 +++-- .../compression/ctoken/compress_and_close.rs | 4 +- .../program/src/ctoken/approve_revoke.rs | 141 +-------------- 5 files changed, 28 insertions(+), 398 deletions(-) diff --git a/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs index 07c33537bf..9d6f9f5ff3 100644 --- a/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs +++ b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs @@ -475,170 +475,3 @@ async fn test_approve_revoke_compressible() -> Result<(), RpcError> { println!("Successfully tested approve and revoke with compressible CToken"); Ok(()) } - -// ============================================================================ -// Approve Checked Tests -// ============================================================================ - -use light_ctoken_sdk::ctoken::ApproveCTokenChecked; -use light_program_test::utils::assert::assert_rpc_error; - -use super::shared::setup_account_test_with_spl_mint; - -/// Test approve checked with correct decimals succeeds -#[tokio::test] -#[serial] -async fn test_approve_checked_success() { - let mut context = setup_account_test_with_spl_mint(9).await.unwrap(); - let payer_pubkey = context.payer.pubkey(); - let mint = context.mint_pubkey; - let delegate = Keypair::new(); - let token_account_keypair = Keypair::new(); - - // Create a token account directly (without assertion that expects specific structure) - let compressible_params = CompressibleParams { - compressible_config: context.compressible_config, - rent_sponsor: context.rent_sponsor, - pre_pay_num_epochs: 2, - lamports_per_write: Some(100), - compress_to_account_pubkey: None, - token_account_version: TokenDataVersion::ShaFlat, - compression_only: false, - }; - - let create_ix = CreateCTokenAccount::new( - payer_pubkey, - token_account_keypair.pubkey(), - mint, - context.owner_keypair.pubkey(), - ) - .with_compressible(compressible_params) - .instruction() - .unwrap(); - - context - .rpc - .create_and_send_transaction( - &[create_ix], - &payer_pubkey, - &[&context.payer, &token_account_keypair], - ) - .await - .unwrap(); - - // Fund owner for compressible top-up - context - .rpc - .airdrop_lamports(&context.owner_keypair.pubkey(), 1_000_000_000) - .await - .unwrap(); - - let approve_ix = ApproveCTokenChecked { - token_account: token_account_keypair.pubkey(), - mint, - delegate: delegate.pubkey(), - owner: context.owner_keypair.pubkey(), - amount: 100, - decimals: 9, // Correct decimals - max_top_up: None, - } - .instruction() - .unwrap(); - - context - .rpc - .create_and_send_transaction( - &[approve_ix], - &payer_pubkey, - &[&context.payer, &context.owner_keypair], - ) - .await - .unwrap(); - - // Verify delegation was set - assert_ctoken_approve( - &mut context.rpc, - token_account_keypair.pubkey(), - delegate.pubkey(), - 100, - ) - .await; - - println!("test_approve_checked_success: passed"); -} - -/// Test approve checked with wrong decimals fails -#[tokio::test] -#[serial] -async fn test_approve_checked_wrong_decimals() { - let mut context = setup_account_test_with_spl_mint(9).await.unwrap(); - let payer_pubkey = context.payer.pubkey(); - let mint = context.mint_pubkey; - let delegate = Keypair::new(); - let token_account_keypair = Keypair::new(); - - // Create a token account directly (without assertion that expects specific structure) - let compressible_params = CompressibleParams { - compressible_config: context.compressible_config, - rent_sponsor: context.rent_sponsor, - pre_pay_num_epochs: 2, - lamports_per_write: Some(100), - compress_to_account_pubkey: None, - token_account_version: TokenDataVersion::ShaFlat, - compression_only: false, - }; - - let create_ix = CreateCTokenAccount::new( - payer_pubkey, - token_account_keypair.pubkey(), - mint, - context.owner_keypair.pubkey(), - ) - .with_compressible(compressible_params) - .instruction() - .unwrap(); - - context - .rpc - .create_and_send_transaction( - &[create_ix], - &payer_pubkey, - &[&context.payer, &token_account_keypair], - ) - .await - .unwrap(); - - // Fund owner for compressible top-up - context - .rpc - .airdrop_lamports(&context.owner_keypair.pubkey(), 1_000_000_000) - .await - .unwrap(); - - // Try to approve with wrong decimals (8 instead of 9) - let approve_ix = ApproveCTokenChecked { - token_account: token_account_keypair.pubkey(), - mint, - delegate: delegate.pubkey(), - owner: context.owner_keypair.pubkey(), - amount: 100, - decimals: 8, // Wrong decimals - max_top_up: None, - } - .instruction() - .unwrap(); - - let result = context - .rpc - .create_and_send_transaction( - &[approve_ix], - &payer_pubkey, - &[&context.payer, &context.owner_keypair], - ) - .await; - - // Should fail because cached decimals (9) mismatch instruction decimals (8) - // When CToken has cached decimals, we return InvalidInstructionData (code 2) - assert_rpc_error(result, 0, 2).unwrap(); - println!("test_approve_checked_wrong_decimals: passed"); -} diff --git a/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs b/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs index b94be727f2..c1be0813c9 100644 --- a/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs +++ b/program-tests/compressed-token-test/tests/ctoken/spl_instruction_compat.rs @@ -416,7 +416,7 @@ async fn test_spl_instruction_compatibility() { /// Test SPL token instruction compatibility with ctoken program using decompressed cmint /// /// This test uses a real decompressed cmint to test instructions that require mint data: -/// - transfer_checked, approve_checked (require decimals validation) +/// - transfer_checked, /// - mint_to, mint_to_checked (require mint authority) /// - burn, burn_checked (require token burning) /// - freeze_account, thaw_account (require freeze authority) @@ -684,68 +684,6 @@ async fn test_spl_instruction_compatibility_with_cmint() { println!("transfer_checked completed successfully"); } - println!("Testing approve_checked using SPL instruction format..."); - - // ApproveChecked using SPL instruction format - { - let delegate = Keypair::new(); - - let mut approve_checked_ix = spl_token_2022::instruction::approve_checked( - &spl_token_2022::ID, - &account1_keypair.pubkey(), - &cmint_pda, - &delegate.pubkey(), - &owner_keypair.pubkey(), - &[], - 200, - decimals, - ) - .unwrap(); - approve_checked_ix.program_id = light_compressed_token::ID; - - rpc.create_and_send_transaction( - &[approve_checked_ix], - &payer_pubkey, - &[&payer, &owner_keypair], - ) - .await - .unwrap(); - - // Verify delegate was set - let account1 = rpc - .get_account(account1_keypair.pubkey()) - .await - .unwrap() - .unwrap(); - let account1_data = - spl_token_2022::state::Account::unpack_unchecked(&account1.data[..165]).unwrap(); - assert_eq!( - account1_data.delegate, - solana_sdk::program_option::COption::Some(delegate.pubkey()), - "Delegate should be set" - ); - assert_eq!( - account1_data.delegated_amount, 200, - "Delegated amount should be 200" - ); - - // Revoke for next tests - let mut revoke_ix = spl_token_2022::instruction::revoke( - &spl_token_2022::ID, - &account1_keypair.pubkey(), - &owner_keypair.pubkey(), - &[], - ) - .unwrap(); - revoke_ix.program_id = light_compressed_token::ID; - - rpc.create_and_send_transaction(&[revoke_ix], &payer_pubkey, &[&payer, &owner_keypair]) - .await - .unwrap(); - - println!("approve_checked completed successfully"); - } - println!("Testing freeze_account using SPL instruction format..."); // FreezeAccount using SPL instruction format @@ -896,7 +834,6 @@ async fn test_spl_instruction_compatibility_with_cmint() { println!(" - mint_to: Minted 1000 tokens"); println!(" - mint_to_checked: Minted 500 tokens with decimals validation"); println!(" - transfer_checked: Transferred 500 tokens with decimals validation"); - println!(" - approve_checked: Approved delegate with decimals validation"); println!(" - freeze_account: Froze account"); println!(" - thaw_account: Thawed account"); println!(" - burn: Burned 100 tokens"); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs index 9b872f645b..ec3cf59db1 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs @@ -1,5 +1,3 @@ -use core::panic; - use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; @@ -118,32 +116,29 @@ pub fn build_mint_extension_cache<'a>( cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; } - if let Some(checks) = cache.get_by_key(&mint_index) { - // CompressAndClose with restricted extensions requires CompressedOnly output. - // Compress/Decompress don't need additional validation here: - // - Compress: blocked by check_mint_extensions when outputs exist - // - Decompress: no check it restores existing state - if checks.has_restricted_extensions && compression.mode.is_compress_and_close() { - let output_idx = compression.get_compressed_token_account_index()?; - let has_compressed_only = inputs - .out_tlv - .as_ref() - .and_then(|tlvs| tlvs.get(output_idx as usize)) - .map(|tlv| { - tlv.iter() - .any(|e| matches!(e, ZExtensionInstructionData::CompressedOnly(_))) - }) - .unwrap_or(false); - if !has_compressed_only { - msg!("Mint has restricted extensions - CompressedOnly output required"); - return Err( - ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into() - ); - } + // SAFETY: mint_index was just inserted above if not already present + let checks = cache.get_by_key(&mint_index).unwrap(); + // CompressAndClose with restricted extensions requires CompressedOnly output. + // Compress/Decompress don't need additional validation here: + // - Compress: blocked by check_mint_extensions when outputs exist + // - Decompress: no check it restores existing state + if checks.has_restricted_extensions && compression.mode.is_compress_and_close() { + let output_idx = compression.get_compressed_token_account_index()?; + let has_compressed_only = inputs + .out_tlv + .as_ref() + .and_then(|tlvs| tlvs.get(output_idx as usize)) + .map(|tlv| { + tlv.iter() + .any(|e| matches!(e, ZExtensionInstructionData::CompressedOnly(_))) + }) + .unwrap_or(false); + if !has_compressed_only { + msg!("Mint has restricted extensions - CompressedOnly output required"); + return Err( + ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into() + ); } - } else { - // TODO: double check. - panic!("Mint cache: compression: mint index not found"); } } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 167aadda51..91cd7348d2 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -47,7 +47,7 @@ pub fn process_compress_and_close( compress_and_close_inputs.ok_or(ErrorCode::CompressAndCloseDestinationMissing)?; // Validate token account - only compressible accounts with compression_authority are allowed - let compress_to_pubkey = validate_token_account_for_close( + let compress_to_pubkey = validate_ctoken_account( &CloseTokenAccountAccounts { token_account: token_account_info, destination: close_inputs.destination, @@ -274,7 +274,7 @@ pub fn close_for_compress_and_close( /// Validates that a ctoken solana account is ready to be closed. /// The rent authority can close the account. #[profile] -pub fn validate_token_account_for_close( +pub fn validate_ctoken_account( accounts: &CloseTokenAccountAccounts, ctoken: &ZCTokenMut<'_>, ) -> Result { diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index 07a74f990a..f054206d6a 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -1,15 +1,11 @@ -use anchor_lang::solana_program::{msg, program_error::ProgramError}; +use anchor_lang::solana_program::program_error::ProgramError; use light_ctoken_interface::{state::CToken, CTokenError}; use pinocchio::account_info::AccountInfo; -use pinocchio_token_program::processor::{ - approve::process_approve, revoke::process_revoke, - shared::approve::process_approve as shared_process_approve, unpack_amount_and_decimals, -}; +use pinocchio_token_program::processor::{approve::process_approve, revoke::process_revoke}; use crate::shared::{ compressible_top_up::process_compression_top_up, convert_pinocchio_token_error, - convert_program_error, convert_token_error, owner_validation::check_token_program_owner, - transfer_lamports_via_cpi, + convert_program_error, transfer_lamports_via_cpi, }; /// Account indices for approve instruction @@ -147,134 +143,3 @@ fn process_compressible_top_up( Ok(()) } - -/// Process CToken approve_checked instruction. -/// Static 4-account layout with cached decimals optimization. -/// -/// Instruction data format: -/// - 9 bytes: amount (8) + decimals (1) - legacy, no max_top_up enforcement -/// - 11 bytes: amount (8) + decimals (1) + max_top_up (2, u16, 0 = no limit) -/// -/// Account layout (always 4 accounts): -/// 0: source CToken account (writable) - may have cached decimals -/// 1: mint account (immutable) - used for validation if no cached decimals -/// 2: delegate (immutable) - the delegate authority -/// 3: owner (signer, writable) - owner of source, payer for top-ups -#[inline(always)] -pub fn process_ctoken_approve_checked( - // TODO: remove this function - accounts: &[AccountInfo], - instruction_data: &[u8], -) -> Result<(), ProgramError> { - if accounts.len() < 4 { - msg!( - "CToken approve_checked: expected at least 4 accounts received {}", - accounts.len() - ); - return Err(ProgramError::NotEnoughAccountKeys); - } - - if instruction_data.len() < 9 { - return Err(ProgramError::InvalidInstructionData); - } - - // Parse amount and decimals from instruction data - let (amount, decimals) = - unpack_amount_and_decimals(instruction_data).map_err(convert_token_error)?; - - // SAFETY: accounts.len() >= 4 validated at function entry - let source = &accounts[APPROVE_CHECKED_ACCOUNT_SOURCE]; - let mint = &accounts[APPROVE_CHECKED_ACCOUNT_MINT]; - - // Hot path: 165-byte accounts have no extensions (no cached decimals, no top-up) - // Validate via mint and use full 4-account layout - if source.data_len() == 165 { - check_token_program_owner(mint)?; - return shared_process_approve(accounts, amount, Some(decimals)) - .map_err(convert_pinocchio_token_error); - } - - // Parse max_top_up from bytes 9-10 if present (0 = no limit) - let max_top_up = match instruction_data.len() { - 9 => 0u16, // Legacy: no max_top_up - 11 => u16::from_le_bytes( - instruction_data[9..11] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - let delegate = &accounts[APPROVE_CHECKED_ACCOUNT_DELEGATE]; - let owner = &accounts[APPROVE_CHECKED_ACCOUNT_OWNER]; - - // Borrow source account to check for cached decimals and handle top-up - let cached_decimals = { - let mut account_data = source - .try_borrow_mut_data() - .map_err(convert_program_error)?; - let (ctoken, _) = CToken::zero_copy_at_mut_checked(&mut account_data)?; - - // Get compressible extension for cached decimals and top-up - let (cached, transfer_amount) = - if let Some(compressible) = ctoken.get_compressible_extension() { - let cached = compressible.decimals(); - - let mut transfer_amount = 0u64; - let mut lamports_budget = if max_top_up == 0 { - u64::MAX - } else { - (max_top_up as u64).saturating_add(1) - }; - - process_compression_top_up( - &compressible.info, - source, - &mut 0, - &mut transfer_amount, - &mut lamports_budget, - &mut None, - )?; - - if transfer_amount > 0 && lamports_budget == 0 { - return Err(CTokenError::MaxTopUpExceeded.into()); - } - (cached, transfer_amount) - } else { - (None, 0) - }; - - // Drop borrow before CPI - drop(account_data); - - if transfer_amount > 0 { - transfer_lamports_via_cpi(transfer_amount, owner, source) - .map_err(convert_program_error)?; - } - - cached - }; - - // Call pinocchio approve based on cached decimals presence - if let Some(cached_decimals) = cached_decimals { - // Validate cached decimals match instruction decimals - if cached_decimals != decimals { - msg!( - "CToken approve_checked: cached decimals {} != instruction decimals {}", - cached_decimals, - decimals - ); - return Err(ProgramError::InvalidInstructionData); - } - // Create 3-account slice [source, delegate, owner] - skip mint - let approve_accounts = [*source, *delegate, *owner]; - shared_process_approve(&approve_accounts, amount, None) - .map_err(convert_pinocchio_token_error) - } else { - // No cached decimals - validate via mint account - check_token_program_owner(mint)?; - // Use full 4-account layout [source, mint, delegate, owner] - shared_process_approve(accounts, amount, Some(decimals)) - .map_err(convert_pinocchio_token_error) - } -} From b3f06675500000a63c599065bcbe900fc9e22d94 Mon Sep 17 00:00:00 2001 From: ananas Date: Fri, 9 Jan 2026 23:40:39 +0000 Subject: [PATCH 27/38] cleanup --- program-libs/ctoken-interface/src/error.rs | 8 ++ .../src/state/ctoken/zero_copy.rs | 84 ++++++++++++++++- .../src/state/mint/zero_copy.rs | 5 + .../ctoken-interface/tests/compressed_mint.rs | 22 +++++ .../tests/ctoken/zero_copy_new.rs | 21 +++++ .../mint_action/mint_output.rs | 74 +++++++-------- .../transfer2/check_extensions.rs | 4 +- .../compression/ctoken/compress_and_close.rs | 94 ++++++++----------- .../program/src/ctoken/approve_revoke.rs | 15 +-- .../program/src/ctoken/close/processor.rs | 18 +--- .../program/src/ctoken/mod.rs | 4 +- .../program/src/ctoken/transfer/shared.rs | 33 +++---- programs/compressed-token/program/src/lib.rs | 15 +-- .../program/src/shared/compressible_top_up.rs | 4 +- 14 files changed, 234 insertions(+), 167 deletions(-) diff --git a/program-libs/ctoken-interface/src/error.rs b/program-libs/ctoken-interface/src/error.rs index 801bedaaa0..8eeeee1483 100644 --- a/program-libs/ctoken-interface/src/error.rs +++ b/program-libs/ctoken-interface/src/error.rs @@ -191,6 +191,12 @@ pub enum CTokenError { #[error("Missing required payer account")] MissingPayer, + + #[error("Failed to borrow account data")] + BorrowFailed, + + #[error("CToken account has invalid owner")] + InvalidCTokenOwner, } impl From for u32 { @@ -257,6 +263,8 @@ impl From for u32 { CTokenError::DecompressDelegatedAmountWithoutDelegate => 18059, CTokenError::DecompressWithheldFeeWithoutExtension => 18060, CTokenError::MissingPayer => 18061, + CTokenError::BorrowFailed => 18062, + CTokenError::InvalidCTokenOwner => 18063, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs index cdfce2d538..94bda53c62 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs @@ -110,7 +110,11 @@ impl<'a> ZeroCopyNew<'a> for CToken { bytes: &'a mut [u8], config: Self::ZeroCopyConfig, ) -> Result<(Self::Output, &'a mut [u8]), light_zero_copy::errors::ZeroCopyError> { - // TODO: check that this function fails if the account is already initialized + // Check that the account is not already initialized (state byte at offset 108) + const STATE_OFFSET: usize = 108; + if bytes.len() > STATE_OFFSET && bytes[STATE_OFFSET] != 0 { + return Err(light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed); + } // Use derived new_zero_copy for base struct (config type is () for fixed-size struct) let (mut base, mut remaining) = >::new_zero_copy(bytes, ())?; @@ -524,6 +528,84 @@ impl CToken { Ok((ctoken, remaining)) } + + /// Deserialize a CToken from account info with validation using zero-copy. + /// Uses unsafe lifetime extension to avoid returning the borrow guard. + /// + /// Checks: + /// 1. Account is owned by the CTOKEN program + /// 2. Account is initialized (state != 0) + /// 3. Account type is ACCOUNT_TYPE_TOKEN_ACCOUNT (byte 165 == 2) + /// 4. No trailing bytes after the CToken structure + /// + /// Safety: The returned ZCToken references the account data which is valid + /// for the duration of the transaction. The caller must ensure the account + /// is not modified through other means while this reference exists. + #[inline(always)] + pub fn from_account_info_checked<'a>( + account_info: &pinocchio::account_info::AccountInfo, + ) -> Result, crate::error::CTokenError> { + // 1. Check program ownership + if !account_info.is_owned_by(&crate::CTOKEN_PROGRAM_ID) { + return Err(crate::error::CTokenError::InvalidCTokenOwner); + } + + let data = account_info + .try_borrow_data() + .map_err(|_| crate::error::CTokenError::BorrowFailed)?; + + // Extend lifetime to 'a - safe because account data lives for transaction duration + let data_slice: &'a [u8] = + unsafe { core::slice::from_raw_parts(data.as_ptr(), data.len()) }; + + let (ctoken, remaining) = CToken::zero_copy_at_checked(data_slice)?; + + // 4. Check no trailing bytes + if !remaining.is_empty() { + return Err(crate::error::CTokenError::InvalidAccountData); + } + + Ok(ctoken) + } + + /// Mutable version of from_account_info_checked. + /// Deserialize a CToken from account info with validation using zero-copy. + /// + /// Checks: + /// 1. Account is owned by the CTOKEN program + /// 2. Account is initialized (state != 0) + /// 3. Account type is ACCOUNT_TYPE_TOKEN_ACCOUNT (byte 165 == 2) + /// 4. No trailing bytes after the CToken structure + /// + /// Safety: The returned ZCTokenMut references the account data which is valid + /// for the duration of the transaction. The caller must ensure the account + /// is not accessed through other means while this mutable reference exists. + #[inline(always)] + pub fn from_account_info_mut_checked<'a>( + account_info: &pinocchio::account_info::AccountInfo, + ) -> Result, crate::error::CTokenError> { + // 1. Check program ownership + if !account_info.is_owned_by(&crate::CTOKEN_PROGRAM_ID) { + return Err(crate::error::CTokenError::InvalidCTokenOwner); + } + + let mut data = account_info + .try_borrow_mut_data() + .map_err(|_| crate::error::CTokenError::BorrowFailed)?; + + // Extend lifetime to 'a - safe because account data lives for transaction duration + let data_slice: &'a mut [u8] = + unsafe { core::slice::from_raw_parts_mut(data.as_mut_ptr(), data.len()) }; + + let (ctoken, remaining) = CToken::zero_copy_at_mut_checked(data_slice)?; + + // 4. Check no trailing bytes + if !remaining.is_empty() { + return Err(crate::error::CTokenError::InvalidAccountData); + } + + Ok(ctoken) + } } #[cfg(feature = "test-only")] diff --git a/program-libs/ctoken-interface/src/state/mint/zero_copy.rs b/program-libs/ctoken-interface/src/state/mint/zero_copy.rs index 65c3b6e838..055e44fa04 100644 --- a/program-libs/ctoken-interface/src/state/mint/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/mint/zero_copy.rs @@ -107,6 +107,11 @@ impl<'a> ZeroCopyNew<'a> for CompressedMint { bytes: &'a mut [u8], config: Self::ZeroCopyConfig, ) -> Result<(Self::Output, &'a mut [u8]), light_zero_copy::errors::ZeroCopyError> { + // Check that the account is not already initialized (is_initialized byte at offset 45) + const IS_INITIALIZED_OFFSET: usize = 45; // 4 + 32 + 8 + 1 = 45 + if bytes.len() > IS_INITIALIZED_OFFSET && bytes[IS_INITIALIZED_OFFSET] != 0 { + return Err(light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed); + } // Use derived new_zero_copy for meta struct let meta_config = CompressedMintZeroCopyMetaConfig { metadata: (), diff --git a/program-libs/ctoken-interface/tests/compressed_mint.rs b/program-libs/ctoken-interface/tests/compressed_mint.rs index 9830c5c533..a4edb1f749 100644 --- a/program-libs/ctoken-interface/tests/compressed_mint.rs +++ b/program-libs/ctoken-interface/tests/compressed_mint.rs @@ -391,3 +391,25 @@ fn test_base_mint_in_compressed_mint_spl_format() { let base_mint = BaseMint::deserialize(&mut base_mint_bytes.to_vec().as_slice()).unwrap(); assert_eq!(mint.base, base_mint); } + +#[test] +fn test_compressed_mint_new_zero_copy_fails_if_already_initialized() { + let config = CompressedMintConfig { extensions: None }; + let byte_len = CompressedMint::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; byte_len]; + + // First initialization should succeed + let _ = CompressedMint::new_zero_copy(&mut buffer, config.clone()) + .expect("First init should succeed"); + + // Second initialization should fail because account is already initialized + let result = CompressedMint::new_zero_copy(&mut buffer, config); + assert!( + result.is_err(), + "new_zero_copy should fail if account is already initialized" + ); + assert_eq!( + result.unwrap_err(), + light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed + ); +} diff --git a/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs b/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs index 97c4572e84..22effe6182 100644 --- a/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs +++ b/program-libs/ctoken-interface/tests/ctoken/zero_copy_new.rs @@ -111,3 +111,24 @@ fn test_compressed_token_byte_len_consistency() { assert!(size_with_ext > size_no_ext); } + +#[test] +fn test_new_zero_copy_fails_if_already_initialized() { + let config = default_config(); + let required_size = CToken::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; required_size]; + + // First initialization should succeed + let _ = CToken::new_zero_copy(&mut buffer, config.clone()).expect("First init should succeed"); + + // Second initialization should fail because account is already initialized + let result = CToken::new_zero_copy(&mut buffer, config); + assert!( + result.is_err(), + "new_zero_copy should fail if account is already initialized" + ); + assert_eq!( + result.unwrap_err(), + light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed + ); +} diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index d0ede11113..66a88477a8 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -9,7 +9,7 @@ use light_ctoken_interface::{ }; use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; -use pinocchio::sysvars::{clock::Clock, rent::Rent, Sysvar}; +use pinocchio::sysvars::{clock::Clock, Sysvar}; use spl_pod::solana_msg::msg; use crate::{ @@ -133,64 +133,54 @@ fn serialize_decompressed_mint( let cmint_account = validated_accounts .get_cmint() .ok_or(ErrorCode::CMintNotFound)?; - let num_bytes = cmint_account.data_len() as u64; + + // STEP 1: Serialize FIRST to know final size + let serialized = compressed_mint + .try_to_vec() + .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)?; + let required_size = serialized.len(); + + // STEP 2: Resize if needed (before lamport calculations) + if cmint_account.data_len() != required_size { + cmint_account + .resize(required_size) + .map_err(|_| ErrorCode::CMintResizeFailed)?; + } + + // STEP 3: Calculate rent exemption deficit FIRST (based on final size) + let num_bytes = required_size as u64; let current_lamports = cmint_account.lamports(); let rent_exemption = get_rent_exemption_lamports(num_bytes).map_err(|_| ErrorCode::CMintRentExemptionFailed)?; - // Skip top-up calculation if decompress mint action is present - // (rent was just paid during account creation). + // Start with rent exemption deficit + let mut deficit = rent_exemption.saturating_sub(current_lamports); + + // STEP 4: Add compressible top-up if not a fresh decompress if !accounts_config.has_decompress_mint_action { let current_slot = Clock::get().map_err(convert_program_error)?.slot; let top_up = compressed_mint .compression .calculate_top_up_lamports(num_bytes, current_slot, current_lamports, rent_exemption) .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; - - if top_up > 0 { - let fee_payer = validated_accounts - .executing - .as_ref() - .map(|exec| exec.system.fee_payer) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - // TODO: unify with other transfer and move top up after resize to calculate based on new size. - transfer_lamports(top_up, fee_payer, cmint_account).map_err(convert_program_error)?; - } + // Add compressible top-up to rent deficit + deficit = deficit.saturating_add(top_up); } - let serialized = compressed_mint - .try_to_vec() - .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)?; - let required_size = serialized.len(); - - // Resize if needed (e.g., metadata extensions added) - if cmint_account.data_len() != required_size { - cmint_account - .resize(required_size) - .map_err(|_| ErrorCode::CMintResizeFailed)?; - - // Transfer additional lamports for rent if resized - let rent = Rent::get().map_err(|_| ProgramError::UnsupportedSysvar)?; - let required_lamports = rent.minimum_balance(required_size); - if cmint_account.lamports() < required_lamports { - let fee_payer = validated_accounts - .executing - .as_ref() - .map(|exec| exec.system.fee_payer) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - transfer_lamports( - required_lamports - cmint_account.lamports(), - fee_payer, - cmint_account, - ) - .map_err(convert_program_error)?; - } + // STEP 5: Single unified transfer if needed + if deficit > 0 { + let fee_payer = validated_accounts + .executing + .as_ref() + .map(|exec| exec.system.fee_payer) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + transfer_lamports(deficit, fee_payer, cmint_account).map_err(convert_program_error)?; } + // STEP 6: Write serialized data let mut cmint_data = cmint_account .try_borrow_mut_data() .map_err(|_| ProgramError::AccountBorrowFailed)?; - // SAFETY: we previously resized the account if needed cmint_data[..serialized.len()].copy_from_slice(&serialized); Ok(()) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs index ec3cf59db1..f3c67fd536 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs @@ -135,9 +135,7 @@ pub fn build_mint_extension_cache<'a>( .unwrap_or(false); if !has_compressed_only { msg!("Mint has restricted extensions - CompressedOnly output required"); - return Err( - ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into() - ); + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); } } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 91cd7348d2..f13c95ae3e 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -11,20 +11,17 @@ use light_ctoken_interface::{ CTokenError, }; use light_program_profiler::profile; -#[cfg(target_os = "solana")] -use pinocchio::sysvars::Sysvar; use pinocchio::{ account_info::AccountInfo, pubkey::{pubkey_eq, Pubkey}, + sysvars::Sysvar, }; use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; -#[cfg(target_os = "solana")] -use crate::shared::convert_program_error; use crate::{ compressed_token::transfer2::accounts::Transfer2Accounts, - ctoken::close::accounts::CloseTokenAccountAccounts, + ctoken::close::accounts::CloseTokenAccountAccounts, shared::convert_program_error, }; /// Process compress and close operation for a ctoken account. @@ -47,13 +44,10 @@ pub fn process_compress_and_close( compress_and_close_inputs.ok_or(ErrorCode::CompressAndCloseDestinationMissing)?; // Validate token account - only compressible accounts with compression_authority are allowed - let compress_to_pubkey = validate_ctoken_account( - &CloseTokenAccountAccounts { - token_account: token_account_info, - destination: close_inputs.destination, - authority, - rent_sponsor: Some(close_inputs.rent_sponsor), - }, + validate_ctoken_account( + token_account_info, + authority, + close_inputs.rent_sponsor, ctoken, )?; @@ -66,18 +60,15 @@ pub fn process_compress_and_close( amount, compressed_account, ctoken, - compress_to_pubkey, token_account_info.key(), close_inputs.tlv, )?; - // TODO: remove once we separated close logic for compress and close - //TODO: introduce ready to close state and set it here - { - ctoken.base.amount.set(0); - // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) - // This allows the close_token_account validation to pass for frozen accounts - ctoken.base.set_initialized(); - } + + ctoken.base.amount.set(0); + // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) + // This allows the close_token_account validation to pass for frozen accounts + ctoken.base.set_initialized(); + Ok(()) } @@ -100,7 +91,6 @@ fn validate_compressed_token_account( compression_amount: u64, compressed_token_account: &ZMultiTokenTransferOutputData<'_>, ctoken: &ZCTokenMut, - compress_to_pubkey: bool, token_account_pubkey: &Pubkey, out_tlv: Option<&[ZExtensionInstructionData<'_>]>, ) -> Result<(), ProgramError> { @@ -109,10 +99,11 @@ fn validate_compressed_token_account( .ok_or::(CTokenError::MissingCompressibleExtension.into())?; // 1. Owner validation + // compress_to_pubkey is derived from the extension (already fetched above) let output_owner = packed_accounts .get_u8(compressed_token_account.owner, "owner")? .key(); - let expected_owner = if compress_to_pubkey || compression.is_ata() { + let expected_owner = if compression.info.compress_to_pubkey() || compression.is_ata() { token_account_pubkey } else { &ctoken.owner.to_bytes() @@ -271,17 +262,15 @@ pub fn close_for_compress_and_close( Ok(()) } -/// Validates that a ctoken solana account is ready to be closed. -/// The rent authority can close the account. +/// Validates that a ctoken solana account is ready to be compressed and closed. +/// Only the compression_authority can compress the account. #[profile] -pub fn validate_ctoken_account( - accounts: &CloseTokenAccountAccounts, +fn validate_ctoken_account( + token_account: &AccountInfo, + authority: &AccountInfo, + rent_sponsor: &AccountInfo, ctoken: &ZCTokenMut<'_>, -) -> Result { - if accounts.token_account.key() == accounts.destination.key() { - return Err(ProgramError::InvalidAccountData); - } - +) -> Result<(), ProgramError> { // Check for Compressible extension let compressible = ctoken.get_compressible_extension(); @@ -292,40 +281,31 @@ pub fn validate_ctoken_account( })?; // Validate rent_sponsor matches - let rent_sponsor = accounts - .rent_sponsor - .ok_or(ProgramError::NotEnoughAccountKeys)?; if compression.info.rent_sponsor != *rent_sponsor.key() { msg!("rent recipient mismatch"); return Err(ProgramError::InvalidAccountData); } - if compression.info.compression_authority != *accounts.authority.key() { + if compression.info.compression_authority != *authority.key() { msg!("compress and close requires compression authority"); return Err(ProgramError::InvalidAccountData); } - #[cfg(target_os = "solana")] - { - let current_slot = pinocchio::sysvars::clock::Clock::get() - .map_err(convert_program_error)? - .slot; - let is_compressible = compression - .info - .is_compressible( - accounts.token_account.data_len() as u64, - current_slot, - accounts.token_account.lamports(), - ) - .map_err(|_| ProgramError::InvalidAccountData)?; - - if is_compressible.is_none() { + let current_slot = pinocchio::sysvars::clock::Clock::get() + .map_err(convert_program_error)? + .slot; + compression + .info + .is_compressible( + token_account.data_len() as u64, + current_slot, + token_account.lamports(), + ) + .map_err(|_| ProgramError::InvalidAccountData)? + .ok_or_else(|| { msg!("account not compressible"); - return Err(ProgramError::InvalidAccountData); - } - } + ProgramError::InvalidAccountData + })?; - // Return true if either compress_to_pubkey is set OR this is an ATA - // When true, the compressed account owner will be the token account pubkey - Ok(compression.info.compress_to_pubkey() || compression.is_ata()) + Ok(()) } diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index f054206d6a..ffd58f51e7 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -12,12 +12,6 @@ use crate::shared::{ const APPROVE_ACCOUNT_SOURCE: usize = 0; const APPROVE_ACCOUNT_OWNER: usize = 2; // owner is payer for top-up -/// Account indices for approve_checked instruction (static 4-account layout) -const APPROVE_CHECKED_ACCOUNT_SOURCE: usize = 0; -const APPROVE_CHECKED_ACCOUNT_MINT: usize = 1; -const APPROVE_CHECKED_ACCOUNT_DELEGATE: usize = 2; -const APPROVE_CHECKED_ACCOUNT_OWNER: usize = 3; - /// Account indices for revoke instruction const REVOKE_ACCOUNT_SOURCE: usize = 0; const REVOKE_ACCOUNT_OWNER: usize = 1; // owner is payer for top-up @@ -105,11 +99,7 @@ fn process_compressible_top_up( payer: Option<&AccountInfo>, max_top_up: u16, ) -> Result<(), ProgramError> { - // Borrow account data to get extensions - let mut account_data = account - .try_borrow_mut_data() - .map_err(convert_program_error)?; - let (ctoken, _) = CToken::zero_copy_at_mut_checked(&mut account_data)?; + let ctoken = CToken::from_account_info_mut_checked(account)?; // Only process top-up if account has Compressible extension let transfer_amount = if let Some(compressible) = ctoken.get_compressible_extension() { @@ -132,9 +122,6 @@ fn process_compressible_top_up( 0 }; - // Drop borrow before CPI - drop(account_data); - if transfer_amount > 0 { let payer = payer.ok_or(CTokenError::MissingPayer)?; transfer_lamports_via_cpi(transfer_amount, payer, account) diff --git a/programs/compressed-token/program/src/ctoken/close/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs index efafb9acc9..7c5a86ed10 100644 --- a/programs/compressed-token/program/src/ctoken/close/processor.rs +++ b/programs/compressed-token/program/src/ctoken/close/processor.rs @@ -1,9 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; -use light_account_checks::{ - checks::{check_owner, check_signer}, - AccountInfoTrait, -}; +use light_account_checks::{checks::check_signer, AccountInfoTrait}; use light_compressible::rent::{get_rent_exemption_lamports, AccountRentState}; use light_ctoken_interface::state::{AccountState, CToken, ZCTokenMut}; use light_program_profiler::profile; @@ -13,10 +10,7 @@ use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; use spl_pod::solana_msg::msg; use super::accounts::CloseTokenAccountAccounts; -use crate::{ - shared::{convert_program_error, transfer_lamports}, - LIGHT_CPI_SIGNER, -}; +use crate::shared::{convert_program_error, transfer_lamports}; /// Process the close token account instruction #[profile] @@ -28,9 +22,7 @@ pub fn process_close_token_account( let accounts = CloseTokenAccountAccounts::validate_and_parse(account_infos)?; { // Try to parse as CToken using zero-copy deserialization - let token_account_data = - &mut AccountInfoTrait::try_borrow_mut_data(accounts.token_account)?; - let (ctoken, _) = CToken::zero_copy_at_mut_checked(token_account_data)?; + let ctoken = CToken::from_account_info_mut_checked(accounts.token_account)?; validate_token_account_close(&accounts, &ctoken)?; } close_token_account(&accounts)?; @@ -122,9 +114,7 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( })?; // Check for compressible extension and handle lamport distribution - check_owner(&LIGHT_CPI_SIGNER.program_id, accounts.token_account)?; - let token_account_data = AccountInfoTrait::try_borrow_data(accounts.token_account)?; - let (ctoken, _) = CToken::zero_copy_at_checked(&token_account_data)?; + let ctoken = CToken::from_account_info_checked(accounts.token_account)?; // Check for Compressible extension let compressible = ctoken.get_compressible_extension(); diff --git a/programs/compressed-token/program/src/ctoken/mod.rs b/programs/compressed-token/program/src/ctoken/mod.rs index 441bda4979..44b420ef1b 100644 --- a/programs/compressed-token/program/src/ctoken/mod.rs +++ b/programs/compressed-token/program/src/ctoken/mod.rs @@ -7,9 +7,7 @@ pub mod freeze_thaw; pub mod mint_to; pub mod transfer; -pub use approve_revoke::{ - process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_revoke, -}; +pub use approve_revoke::{process_ctoken_approve, process_ctoken_revoke}; pub use burn::{process_ctoken_burn, process_ctoken_burn_checked}; pub use close::processor::process_close_token_account; pub use create::process_create_token_account; diff --git a/programs/compressed-token/program/src/ctoken/transfer/shared.rs b/programs/compressed-token/program/src/ctoken/transfer/shared.rs index 511c464895..7b21700507 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/shared.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/shared.rs @@ -198,17 +198,19 @@ fn validate_permanent_delegate( mint_checks: Option<&MintExtensionChecks>, authority: &AccountInfo, ) -> Result { - if let Some(checks) = mint_checks { - if let Some(permanent_delegate_pubkey) = checks.permanent_delegate { - if pubkey_eq(authority.key(), &permanent_delegate_pubkey) { - if !authority.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - return Ok(true); - } - } + let Some(checks) = mint_checks else { + return Ok(false); + }; + let Some(permanent_delegate_pubkey) = checks.permanent_delegate else { + return Ok(false); + }; + if !pubkey_eq(authority.key(), &permanent_delegate_pubkey) { + return Ok(false); + } + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); } - Ok(false) + Ok(true) } /// Process account extensions with mutable access. @@ -221,14 +223,7 @@ fn process_account_extensions( current_slot: &mut u64, mint: Option<&AccountInfo>, ) -> Result { - // TODO: replace with from_account_info_checked - let mut account_data = account - .try_borrow_mut_data() - .map_err(convert_program_error)?; - let (token, remaining) = CToken::zero_copy_at_mut_checked(&mut account_data)?; - if !remaining.is_empty() { - return Err(ProgramError::InvalidAccountData); - } + let token = CToken::from_account_info_mut_checked(account)?; // Validate mint account matches token's mint field if let Some(mint_account) = mint { @@ -277,7 +272,7 @@ fn process_account_extensions( ZExtensionStructMut::PermanentDelegateAccount(_) => { info.flags.has_permanent_delegate = true; } - ZExtensionStructMut::TransferFeeAccount(_transfer_fee_ext) => { + ZExtensionStructMut::TransferFeeAccount(_) => { info.flags.has_transfer_fee = true; // Note: Non-zero transfer fees are rejected by check_mint_extensions, // so no fee withholding is needed here. diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index a04e6d92dd..0e1dd0e5d2 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -18,10 +18,10 @@ use compressible::{process_claim, process_withdraw_funding_pool}; use ctoken::{ process_close_token_account, process_create_associated_token_account, process_create_associated_token_account_idempotent, process_create_token_account, - process_ctoken_approve, process_ctoken_approve_checked, process_ctoken_burn, - process_ctoken_burn_checked, process_ctoken_freeze_account, process_ctoken_mint_to, - process_ctoken_mint_to_checked, process_ctoken_revoke, process_ctoken_thaw_account, - process_ctoken_transfer, process_ctoken_transfer_checked, + process_ctoken_approve, process_ctoken_burn, process_ctoken_burn_checked, + process_ctoken_freeze_account, process_ctoken_mint_to, process_ctoken_mint_to_checked, + process_ctoken_revoke, process_ctoken_thaw_account, process_ctoken_transfer, + process_ctoken_transfer_checked, }; use crate::{ @@ -62,8 +62,6 @@ pub enum InstructionType { CTokenThawAccount = 11, /// CToken TransferChecked - transfer with decimals validation (SPL compatible) CTokenTransferChecked = 12, - /// CToken ApproveChecked - approve with decimals validation (SPL compatible) - CTokenApproveChecked = 13, /// CToken MintToChecked - mint with decimals validation CTokenMintToChecked = 14, /// CToken BurnChecked - burn with decimals validation @@ -110,7 +108,6 @@ impl From for InstructionType { 10 => InstructionType::CTokenFreezeAccount, 11 => InstructionType::CTokenThawAccount, 12 => InstructionType::CTokenTransferChecked, - 13 => InstructionType::CTokenApproveChecked, 14 => InstructionType::CTokenMintToChecked, 15 => InstructionType::CTokenBurnChecked, 18 => InstructionType::CreateTokenAccount, @@ -165,10 +162,6 @@ pub fn process_instruction( msg!("CTokenBurn"); process_ctoken_burn(accounts, &instruction_data[1..])?; } - InstructionType::CTokenApproveChecked => { - msg!("CTokenApproveChecked"); - process_ctoken_approve_checked(accounts, &instruction_data[1..])?; - } InstructionType::CTokenMintToChecked => { msg!("CTokenMintToChecked"); process_ctoken_mint_to_checked(accounts, &instruction_data[1..])?; diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index 28699f1e43..92f2e35378 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -67,9 +67,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( // Calculate CToken top-up (only if not 165 bytes - 165 means no extensions) if ctoken.data_len() != 165 { - check_owner(&LIGHT_CPI_SIGNER.program_id, ctoken)?; // TODO: add from account info - let account_data = ctoken.try_borrow_data().map_err(convert_program_error)?; - let (token, _) = CToken::zero_copy_at_checked(&account_data)?; + let token = CToken::from_account_info_checked(ctoken)?; // Check for Compressible extension let compressible = token .get_compressible_extension() From 95f62bb09b6ba14cbabdcfd950f98915d7c95ba3 Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 10 Jan 2026 16:00:21 +0000 Subject: [PATCH 28/38] fix test --- .../compression/ctoken/compress_and_close.rs | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index f13c95ae3e..0f902ef150 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -19,9 +19,10 @@ use pinocchio::{ use spl_pod::solana_msg::msg; use super::inputs::CompressAndCloseInputs; +#[cfg(target_os = "solana")] +use crate::ctoken::close::accounts::CloseTokenAccountAccounts; use crate::{ - compressed_token::transfer2::accounts::Transfer2Accounts, - ctoken::close::accounts::CloseTokenAccountAccounts, shared::convert_program_error, + compressed_token::transfer2::accounts::Transfer2Accounts, shared::convert_program_error, }; /// Process compress and close operation for a ctoken account. @@ -208,6 +209,7 @@ fn validate_compressed_only_ext( } /// Close ctoken accounts after compress and close operations +#[allow(unused_variables)] pub fn close_for_compress_and_close( compressions: &[ZCompression<'_>], validated_accounts: &Transfer2Accounts, @@ -236,28 +238,31 @@ pub fn close_for_compress_and_close( return Err(ProgramError::InvalidInstructionData); } - 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::ctoken::close::processor::close_token_account; - close_token_account(&CloseTokenAccountAccounts { - token_account: token_account_info, - destination, - authority, - rent_sponsor: Some(rent_sponsor), - })?; + #[cfg(target_os = "solana")] + { + 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::ctoken::close::processor::close_token_account; + close_token_account(&CloseTokenAccountAccounts { + token_account: token_account_info, + destination, + authority, + rent_sponsor: Some(rent_sponsor), + })?; + } } Ok(()) } From 0eec18acc56ed4e7565ad90aa1f1ae0693688782 Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 10 Jan 2026 16:45:16 +0000 Subject: [PATCH 29/38] refactor approve revoke, burn, mint to --- .../program/src/ctoken/approve_revoke.rs | 102 +++++++++--------- .../program/src/ctoken/burn.rs | 101 ++++++++--------- .../program/src/ctoken/mint_to.rs | 89 +++------------ 3 files changed, 120 insertions(+), 172 deletions(-) diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index ffd58f51e7..4a97187644 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -8,16 +8,16 @@ use crate::shared::{ convert_program_error, transfer_lamports_via_cpi, }; -/// Account indices for approve instruction -const APPROVE_ACCOUNT_SOURCE: usize = 0; -const APPROVE_ACCOUNT_OWNER: usize = 2; // owner is payer for top-up +/// Approve: 8-byte base (amount), payer at index 2 +const APPROVE_BASE_LEN: usize = 8; +const APPROVE_PAYER_IDX: usize = 2; -/// Account indices for revoke instruction -const REVOKE_ACCOUNT_SOURCE: usize = 0; -const REVOKE_ACCOUNT_OWNER: usize = 1; // owner is payer for top-up +/// Revoke: 0-byte base, payer at index 1 +const REVOKE_BASE_LEN: usize = 0; +const REVOKE_PAYER_IDX: usize = 1; /// Process CToken approve instruction. -/// Handles compressible extension top-up before delegating to pinocchio. +/// Handles compressible extension top-up after delegating to pinocchio. /// /// Instruction data format (backwards compatible): /// - 8 bytes: amount (legacy, no max_top_up enforcement) @@ -27,32 +27,19 @@ pub fn process_ctoken_approve( accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - let source = accounts - .get(APPROVE_ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - process_approve(accounts, &instruction_data[..8]).map_err(convert_pinocchio_token_error)?; - // Hot path: 165-byte accounts have no extensions, just call pinocchio directly - if source.data_len() == 165 { - return Ok(()); + if accounts.is_empty() { + return Err(ProgramError::NotEnoughAccountKeys); } - - let payer = accounts.get(APPROVE_ACCOUNT_OWNER); - - // Parse max_top_up based on instruction data length (0 = 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), - }; - process_compressible_top_up(source, payer, max_top_up) + if instruction_data.len() < APPROVE_BASE_LEN { + return Err(ProgramError::InvalidInstructionData); + } + process_approve(accounts, &instruction_data[..APPROVE_BASE_LEN]) + .map_err(convert_pinocchio_token_error)?; + handle_compressible_top_up::(accounts, instruction_data) } /// Process CToken revoke instruction. -/// Handles compressible extension top-up before delegating to pinocchio. +/// Handles compressible extension top-up after delegating to pinocchio. /// /// Instruction data format (backwards compatible): /// - 0 bytes: legacy, no max_top_up enforcement @@ -62,43 +49,56 @@ pub fn process_ctoken_revoke( accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - let source = accounts - .get(REVOKE_ACCOUNT_SOURCE) - .ok_or(ProgramError::NotEnoughAccountKeys)?; - + if accounts.is_empty() { + return Err(ProgramError::NotEnoughAccountKeys); + } process_revoke(accounts).map_err(convert_pinocchio_token_error)?; + handle_compressible_top_up::(accounts, instruction_data) +} + +/// Handle compressible extension top-up after pinocchio processing. +/// +/// # Type Parameters +/// * `BASE_LEN` - Base instruction data length (8 for approve, 0 for revoke) +/// * `PAYER_IDX` - Index of payer account (2 for approve, 1 for revoke) +#[inline(always)] +fn handle_compressible_top_up( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + let source = &accounts[0]; // Hot path: 165-byte accounts have no extensions if source.data_len() == 165 { return Ok(()); } - let payer = accounts.get(REVOKE_ACCOUNT_OWNER); + process_compressible_top_up::(source, accounts, instruction_data) +} + +/// Calculate and transfer compressible top-up for a single ctoken account. +/// +/// # Type Parameters +/// * `BASE_LEN` - Base instruction data length (8 for approve, 0 for revoke) +/// * `PAYER_IDX` - Index of payer account (2 for approve, 1 for revoke) +#[cold] +fn process_compressible_top_up( + account: &AccountInfo, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + let payer = accounts.get(PAYER_IDX); - // Parse max_top_up based on instruction data length (0 = no limit) let max_top_up = match instruction_data.len() { - 0 => 0u16, // Legacy: no max_top_up - 2 => u16::from_le_bytes( - instruction_data[0..2] + len if len == BASE_LEN => 0u16, + len if len == BASE_LEN + 2 => u16::from_le_bytes( + instruction_data[BASE_LEN..BASE_LEN + 2] .try_into() .map_err(|_| ProgramError::InvalidInstructionData)?, ), _ => return Err(ProgramError::InvalidInstructionData), }; - process_compressible_top_up(source, payer, max_top_up) -} - -/// Calculate and transfer compressible top-up for a single account. -/// -/// # Arguments -/// * `max_top_up` - Maximum lamports for top-up. Transaction fails if exceeded. (0 = no limit) -#[inline(always)] -fn process_compressible_top_up( - account: &AccountInfo, - payer: Option<&AccountInfo>, - max_top_up: u16, -) -> Result<(), ProgramError> { let ctoken = CToken::from_account_info_mut_checked(account)?; // Only process top-up if account has Compressible extension diff --git a/programs/compressed-token/program/src/ctoken/burn.rs b/programs/compressed-token/program/src/ctoken/burn.rs index aedd2b8f15..df3d2a9df5 100644 --- a/programs/compressed-token/program/src/ctoken/burn.rs +++ b/programs/compressed-token/program/src/ctoken/burn.rs @@ -1,12 +1,23 @@ use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; +use pinocchio::program_error::ProgramError as PinocchioProgramError; use pinocchio_token_program::processor::{burn::process_burn, burn_checked::process_burn_checked}; use crate::shared::{ compressible_top_up::calculate_and_execute_compressible_top_ups, convert_pinocchio_token_error, }; +pub(crate) type ProcessorFn = fn(&[AccountInfo], &[u8]) -> Result<(), PinocchioProgramError>; + +/// Base instruction data length constants +pub(crate) const BASE_LEN_UNCHECKED: usize = 8; +pub(crate) const BASE_LEN_CHECKED: usize = 9; + +/// Burn account indices: [ctoken=0, cmint=1, authority=2] +const BURN_CMINT_IDX: usize = 1; +const BURN_CTOKEN_IDX: usize = 0; + /// Process ctoken burn instruction /// /// Instruction data format (same as CTokenTransfer/CTokenMintTo): @@ -23,40 +34,11 @@ pub fn process_ctoken_burn( accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - if accounts.len() < 3 { - msg!( - "CToken burn: expected at least 3 accounts received {}", - accounts.len() - ); - return Err(ProgramError::NotEnoughAccountKeys); - } - - if instruction_data.len() < 8 { - return Err(ProgramError::InvalidInstructionData); - } - - // Parse max_top_up - let max_top_up = match instruction_data.len() { - 8 => 0u16, - 10 => u16::from_le_bytes( - instruction_data[8..10] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - // Call pinocchio burn - handles balance/supply updates, authority check, frozen check - process_burn(accounts, &instruction_data[..8]).map_err(convert_pinocchio_token_error)?; - - // Calculate and execute top-ups for both CMint and CToken - // burn account order: [ctoken, cmint, authority] - reverse of mint_to - // SAFETY: accounts.len() >= 3 validated at function entry - let ctoken = &accounts[0]; - let cmint = &accounts[1]; - let payer = accounts.get(2); - - calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_burn, + ) } /// Process ctoken burn_checked instruction @@ -74,39 +56,60 @@ pub fn process_ctoken_burn( pub fn process_ctoken_burn_checked( accounts: &[AccountInfo], instruction_data: &[u8], +) -> Result<(), ProgramError> { + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_burn_checked, + ) +} + +/// Shared inner implementation for ctoken mint_to and burn variants. +/// +/// # Type Parameters +/// * `BASE_LEN` - Base instruction data length (8 for unchecked, 9 for checked) +/// * `CMINT_IDX` - Index of CMint account (0 for mint_to, 1 for burn) +/// * `CTOKEN_IDX` - Index of CToken account (1 for mint_to, 0 for burn) +/// +/// # Arguments +/// * `accounts` - Account layout: [cmint/ctoken, ctoken/cmint, authority] +/// * `instruction_data` - Serialized instruction data +/// * `processor` - Pinocchio processor function +#[inline(always)] +pub(crate) fn process_ctoken_supply_change_inner< + const BASE_LEN: usize, + const CMINT_IDX: usize, + const CTOKEN_IDX: usize, +>( + accounts: &[AccountInfo], + instruction_data: &[u8], + processor: ProcessorFn, ) -> Result<(), ProgramError> { if accounts.len() < 3 { - msg!( - "CToken burn_checked: expected at least 3 accounts received {}", - accounts.len() - ); + msg!("CToken: expected at least 3 accounts received {}", accounts.len()); return Err(ProgramError::NotEnoughAccountKeys); } - if instruction_data.len() < 9 { + if instruction_data.len() < BASE_LEN { return Err(ProgramError::InvalidInstructionData); } - // Parse max_top_up from bytes 9-10 if present let max_top_up = match instruction_data.len() { - 9 => 0u16, // Legacy: no max_top_up - 11 => u16::from_le_bytes( - instruction_data[9..11] + len if len == BASE_LEN => 0u16, + len if len == BASE_LEN + 2 => u16::from_le_bytes( + instruction_data[BASE_LEN..BASE_LEN + 2] .try_into() .map_err(|_| ProgramError::InvalidInstructionData)?, ), _ => return Err(ProgramError::InvalidInstructionData), }; - // Call pinocchio burn_checked - validates decimals against CMint, handles balance/supply updates - process_burn_checked(accounts, &instruction_data[..9]) - .map_err(convert_pinocchio_token_error)?; + processor(accounts, &instruction_data[..BASE_LEN]).map_err(convert_pinocchio_token_error)?; // Calculate and execute top-ups for both CMint and CToken - // burn account order: [ctoken, cmint, authority] - reverse of mint_to // SAFETY: accounts.len() >= 3 validated at function entry - let ctoken = &accounts[0]; - let cmint = &accounts[1]; + let cmint = &accounts[CMINT_IDX]; + let ctoken = &accounts[CTOKEN_IDX]; let payer = accounts.get(2); calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) diff --git a/programs/compressed-token/program/src/ctoken/mint_to.rs b/programs/compressed-token/program/src/ctoken/mint_to.rs index 66c9f2bee5..06da59ba68 100644 --- a/programs/compressed-token/program/src/ctoken/mint_to.rs +++ b/programs/compressed-token/program/src/ctoken/mint_to.rs @@ -1,14 +1,18 @@ -use anchor_lang::solana_program::{msg, program_error::ProgramError}; +use anchor_lang::solana_program::program_error::ProgramError; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::{ mint_to::process_mint_to, mint_to_checked::process_mint_to_checked, }; -use crate::shared::{ - compressible_top_up::calculate_and_execute_compressible_top_ups, convert_pinocchio_token_error, +use super::burn::{ + process_ctoken_supply_change_inner, BASE_LEN_CHECKED, BASE_LEN_UNCHECKED, }; +/// Mint account indices: [cmint=0, ctoken=1, authority=2] +pub(crate) const MINT_CMINT_IDX: usize = 0; +pub(crate) const MINT_CTOKEN_IDX: usize = 1; + /// Process ctoken mint_to instruction /// /// Instruction data format (same as CTokenTransfer): @@ -25,40 +29,11 @@ pub fn process_ctoken_mint_to( accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - if accounts.len() < 3 { - msg!( - "CToken mint_to: expected at least 3 accounts received {}", - accounts.len() - ); - return Err(ProgramError::NotEnoughAccountKeys); - } - - if instruction_data.len() < 8 { - return Err(ProgramError::InvalidInstructionData); - } - - // Parse max_top_up (same pattern as ctoken_transfer.rs) - let max_top_up = match instruction_data.len() { - 8 => 0u16, - 10 => u16::from_le_bytes( - instruction_data[8..10] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - // Call pinocchio mint_to - handles supply/balance updates, authority check, frozen check - process_mint_to(accounts, &instruction_data[..8]).map_err(convert_pinocchio_token_error)?; - - // Calculate and execute top-ups for both CMint and CToken - // mint_to account order: [cmint, ctoken, authority] - // SAFETY: accounts.len() >= 3 validated at function entry - let cmint = &accounts[0]; - let ctoken = &accounts[1]; - let payer = accounts.get(2); - - calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_mint_to, + ) } /// Process ctoken mint_to_checked instruction @@ -77,39 +52,9 @@ pub fn process_ctoken_mint_to_checked( accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - if accounts.len() < 3 { - msg!( - "CToken mint_to_checked: expected at least 3 accounts received {}", - accounts.len() - ); - return Err(ProgramError::NotEnoughAccountKeys); - } - - if instruction_data.len() < 9 { - return Err(ProgramError::InvalidInstructionData); - } - - // Parse max_top_up from bytes 9-10 if present - let max_top_up = match instruction_data.len() { - 9 => 0u16, // Legacy: no max_top_up - 11 => u16::from_le_bytes( - instruction_data[9..11] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - // Call pinocchio mint_to_checked - validates decimals against CMint, handles supply/balance updates - process_mint_to_checked(accounts, &instruction_data[..9]) - .map_err(convert_pinocchio_token_error)?; - - // Calculate and execute top-ups for both CMint and CToken - // mint_to account order: [cmint, ctoken, authority] - // SAFETY: accounts.len() >= 3 validated at function entry - let cmint = &accounts[0]; - let ctoken = &accounts[1]; - let payer = accounts.get(2); - - calculate_and_execute_compressible_top_ups(cmint, ctoken, payer, max_top_up) + process_ctoken_supply_change_inner::( + accounts, + instruction_data, + process_mint_to_checked, + ) } From 44b9c8e21fcd9c738d1e72c9f11a2b2b2e97deba Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 10 Jan 2026 19:48:45 +0000 Subject: [PATCH 30/38] fix: rent exemption in case that rent exemption sysvar changes --- program-libs/compressible/docs/SOLANA_RENT.md | 2 +- .../compressible/src/compression_info.rs | 39 ++++----- .../compressible/tests/compression_info.rs | 48 +++++----- .../compressible/tests/consistency.rs | 9 +- program-libs/compressible/tests/top_up.rs | 33 ++----- .../ctoken-interface/src/constants.rs | 2 +- .../ctoken-interface/src/state/ctoken/mod.rs | 2 + .../src/state/ctoken/top_up.rs | 80 +++++++++++++++++ .../ctoken-interface/src/state/mint/mod.rs | 2 + .../ctoken-interface/src/state/mint/top_up.rs | 87 +++++++++++++++++++ .../ctoken-interface/tests/compressed_mint.rs | 46 ++++++++++ .../tests/cross_deserialization.rs | 4 + .../ctoken-interface/tests/ctoken/mod.rs | 1 + .../ctoken-interface/tests/ctoken/top_up.rs | 77 ++++++++++++++++ .../tests/compress_only/default_state.rs | 6 +- program-tests/utils/src/assert_claim.rs | 12 +-- .../utils/src/assert_close_token_account.rs | 5 +- .../utils/src/assert_create_token_account.rs | 2 + program-tests/utils/src/assert_ctoken_burn.rs | 13 +-- .../utils/src/assert_ctoken_mint_to.rs | 13 +-- .../utils/src/assert_ctoken_transfer.rs | 11 +-- program-tests/utils/src/assert_mint_action.rs | 6 +- .../mint_action/actions/decompress_mint.rs | 12 ++- .../mint_action/mint_output.rs | 12 ++- .../ctoken/compress_or_decompress_ctokens.rs | 2 - .../program/src/ctoken/approve_revoke.rs | 78 ++++++++--------- .../program/src/ctoken/burn.rs | 8 +- .../program/src/ctoken/close/processor.rs | 6 +- .../program/src/ctoken/mint_to.rs | 4 +- .../program/src/ctoken/transfer/shared.rs | 13 +-- .../program/src/shared/compressible_top_up.rs | 69 ++++++--------- .../src/shared/initialize_ctoken_account.rs | 21 ++++- .../program/src/shared/transfer_lamports.rs | 2 +- .../compressed-token/program/tests/mint.rs | 2 + 34 files changed, 473 insertions(+), 256 deletions(-) create mode 100644 program-libs/ctoken-interface/src/state/ctoken/top_up.rs create mode 100644 program-libs/ctoken-interface/src/state/mint/top_up.rs create mode 100644 program-libs/ctoken-interface/tests/ctoken/top_up.rs diff --git a/program-libs/compressible/docs/SOLANA_RENT.md b/program-libs/compressible/docs/SOLANA_RENT.md index 12f494d171..4d2cdcb7ad 100644 --- a/program-libs/compressible/docs/SOLANA_RENT.md +++ b/program-libs/compressible/docs/SOLANA_RENT.md @@ -42,7 +42,7 @@ Light Protocol's rent system is designed for **compressible token accounts** wit | **Exemption** | Permanent with sufficient balance | Temporary, epoch-by-epoch | | **Collection** | Automatic by runtime | Manual via Claim instruction | | **Distribution** | 50% burned, 50% to validators | 100% to rent recipient (protocol) | -| **Rent-Specific Data** | None (uses account balance) | 88 bytes (CompressionInfo) | +| **Rent-Specific Data** | None (uses account balance) | 96 bytes (CompressionInfo) | | **Compression** | N/A | Incentivized with 11,000 lamport bonus | ### Rent Calculation Comparison diff --git a/program-libs/compressible/src/compression_info.rs b/program-libs/compressible/src/compression_info.rs index cd46ceff75..affac0bc34 100644 --- a/program-libs/compressible/src/compression_info.rs +++ b/program-libs/compressible/src/compression_info.rs @@ -8,10 +8,7 @@ use zerocopy::U64; use crate::{ config::CompressibleConfig, error::CompressibleError, - rent::{ - get_last_funded_epoch, get_rent_exemption_lamports, AccountRentState, RentConfig, - SLOTS_PER_EPOCH, - }, + rent::{get_last_funded_epoch, AccountRentState, RentConfig, SLOTS_PER_EPOCH}, AnchorDeserialize, AnchorSerialize, }; @@ -22,7 +19,6 @@ pub trait CalculateTopUp { num_bytes: u64, current_slot: u64, current_lamports: u64, - rent_exemption_lamports: u64, ) -> Result; } @@ -60,6 +56,12 @@ pub struct CompressionInfo { pub rent_sponsor: [u8; 32], /// Last slot rent was claimed from this account. pub last_claimed_slot: u64, + /// Rent exemption lamports paid at account creation. + /// Used instead of querying the Rent sysvar to ensure rent sponsor + /// gets back exactly what they paid regardless of future rent changes. + pub rent_exemption_paid: u32, + /// Reserved for future use. + pub _reserved: u32, /// Rent function parameters, /// used to calculate whether the account is compressible. pub rent_config: RentConfig, @@ -81,7 +83,8 @@ macro_rules! impl_is_compressible { current_slot: u64, current_lamports: u64, ) -> Result, CompressibleError> { - let rent_exemption_lamports = get_rent_exemption_lamports(bytes)?; + let rent_exemption_paid: u32 = self.rent_exemption_paid.into(); + let rent_exemption_lamports: u64 = rent_exemption_paid as u64; Ok(crate::rent::AccountRentState { num_bytes: bytes, current_slot, @@ -106,9 +109,10 @@ macro_rules! impl_is_compressible { num_bytes: u64, current_slot: u64, current_lamports: u64, - rent_exemption_lamports: u64, ) -> Result { let lamports_per_write: u32 = self.lamports_per_write.into(); + let rent_exemption_paid: u32 = self.rent_exemption_paid.into(); + let rent_exemption_lamports: u64 = rent_exemption_paid as u64; // Calculate rent status using AccountRentState let state = crate::rent::AccountRentState { @@ -150,16 +154,9 @@ macro_rules! impl_calculate_top_up { num_bytes: u64, current_slot: u64, current_lamports: u64, - rent_exemption_lamports: u64, ) -> Result { // Delegate to the inherent method - Self::calculate_top_up_lamports( - self, - num_bytes, - current_slot, - current_lamports, - rent_exemption_lamports, - ) + Self::calculate_top_up_lamports(self, num_bytes, current_slot, current_lamports) } } }; @@ -214,8 +211,9 @@ impl ZCompressionInfoMut<'_> { num_bytes: u64, current_slot: u64, current_lamports: u64, - rent_exemption_lamports: u64, ) -> Result, CompressibleError> { + let rent_exemption_paid: u32 = self.rent_exemption_paid.into(); + let rent_exemption_lamports: u64 = rent_exemption_paid as u64; let state = AccountRentState { num_bytes, current_slot, @@ -274,14 +272,7 @@ impl ZCompressionInfoMut<'_> { return Err(CompressibleError::InvalidVersion); } - let rent_exemption_lamports = get_rent_exemption_lamports(bytes)?; - - let claim_result = self.claim( - bytes, - current_slot, - current_lamports, - rent_exemption_lamports, - )?; + let claim_result = self.claim(bytes, current_slot, current_lamports)?; // Update RentConfig after claim calculation (even if claim_result is None) self.rent_config.set(&config_account.rent_config); diff --git a/program-libs/compressible/tests/compression_info.rs b/program-libs/compressible/tests/compression_info.rs index 2ccf10619e..f729b099d8 100644 --- a/program-libs/compressible/tests/compression_info.rs +++ b/program-libs/compressible/tests/compression_info.rs @@ -21,6 +21,7 @@ pub fn get_rent_exemption_lamports(_num_bytes: u64) -> u64 { #[test] fn test_claim_method() { // Test the claim method updates state correctly + let rent_exemption = get_rent_exemption_lamports(TEST_BYTES) as u32; let extension_data = CompressionInfo { account_version: 3, config_account_version: 1, @@ -29,6 +30,8 @@ fn test_claim_method() { last_claimed_slot: 0, lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption, + _reserved: 0, rent_config: test_rent_config(), }; @@ -46,12 +49,7 @@ fn test_claim_method() { get_rent_exemption_lamports(TEST_BYTES) ); let claimed = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed.unwrap(), @@ -75,7 +73,6 @@ fn test_claim_method() { TEST_BYTES, current_slot, current_lamports - claimed.unwrap_or(0), - get_rent_exemption_lamports(TEST_BYTES), ) .unwrap(); assert_eq!(claimed_again, None, "Should not claim again in same epoch"); @@ -84,12 +81,7 @@ fn test_claim_method() { let current_slot = SLOTS_PER_EPOCH * 3 + 100; let current_lamports = current_lamports - claimed.unwrap_or(0) + RENT_PER_EPOCH - 1; let claimed_again_in_third_epoch = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed_again_in_third_epoch, None, @@ -101,12 +93,7 @@ fn test_claim_method() { let current_slot = SLOTS_PER_EPOCH * 3 + 100; let current_lamports = current_lamports - claimed.unwrap_or(0) + RENT_PER_EPOCH; let claimed_again_in_third_epoch = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed_again_in_third_epoch, @@ -119,12 +106,7 @@ fn test_claim_method() { let current_slot = SLOTS_PER_EPOCH * 4 + 100; let current_lamports = current_lamports - claimed.unwrap_or(0) + 10 * RENT_PER_EPOCH; let claimed_again_in_third_epoch = z_extension - .claim( - TEST_BYTES, - current_slot, - current_lamports, - get_rent_exemption_lamports(TEST_BYTES), - ) + .claim(TEST_BYTES, current_slot, current_lamports) .unwrap(); assert_eq!( claimed_again_in_third_epoch, @@ -149,6 +131,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: 0, // Created in epoch 0 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -174,6 +158,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH - 1, // Created in epoch 0 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -199,6 +185,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH, // Created in epoch 1 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -221,6 +209,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH * 2, // Created in epoch 2 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -243,6 +233,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: 0, lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -263,6 +255,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH * 5, // Created in epoch 5 lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -287,6 +281,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: 0, lamports_per_write: 0, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; @@ -312,6 +308,8 @@ fn test_get_last_paid_epoch() { last_claimed_slot: SLOTS_PER_EPOCH * 3, // Epoch 3 lamports_per_write: 100, compress_to_pubkey: 0, + rent_exemption_paid: 0, + _reserved: 0, rent_config: test_rent_config(), }; diff --git a/program-libs/compressible/tests/consistency.rs b/program-libs/compressible/tests/consistency.rs index a31d3babba..58dd9932dc 100644 --- a/program-libs/compressible/tests/consistency.rs +++ b/program-libs/compressible/tests/consistency.rs @@ -54,12 +54,7 @@ impl RentState { rent_exemption_lamports, ); let top_up = compression_info - .calculate_top_up_lamports( - state.num_bytes, - state.current_slot, - state.current_lamports, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(state.num_bytes, state.current_slot, state.current_lamports) .unwrap(); Self { @@ -182,6 +177,8 @@ fn test_consistency_16_epochs_progression() { last_claimed_slot, lamports_per_write, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption_lamports as u32, + _reserved: 0, rent_config: test_rent_config(), }; diff --git a/program-libs/compressible/tests/top_up.rs b/program-libs/compressible/tests/top_up.rs index 1d32105652..245565f0bf 100644 --- a/program-libs/compressible/tests/top_up.rs +++ b/program-libs/compressible/tests/top_up.rs @@ -257,6 +257,8 @@ fn test_calculate_top_up_lamports() { last_claimed_slot: test_case.last_claimed_slot, lamports_per_write: test_case.lamports_per_write, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption_lamports as u32, + _reserved: 0, rent_config: test_rent_config(), }; @@ -265,7 +267,6 @@ fn test_calculate_top_up_lamports() { TEST_BYTES, test_case.current_slot, test_case.current_lamports, - rent_exemption_lamports, ) .unwrap(); @@ -297,6 +298,8 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { last_claimed_slot: start_slot, lamports_per_write, compress_to_pubkey: 0, + rent_exemption_paid: rent_exemption_lamports as u32, + _reserved: 0, rent_config: test_rent_config(), // max_funded_epochs = 2 }; @@ -305,12 +308,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { let current_slot = start_slot + (SLOTS_PER_EPOCH * epoch_offset); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - current_slot, - initial_lamports, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, current_slot, initial_lamports) .unwrap(); assert_eq!( @@ -325,12 +323,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { // Epoch 14 - should require top-up (only 1 epoch funded ahead < max_funded_epochs=2) let epoch_14_slot = start_slot + (SLOTS_PER_EPOCH * 14); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - epoch_14_slot, - initial_lamports, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, epoch_14_slot, initial_lamports) .unwrap(); assert_eq!( @@ -346,12 +339,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { let current_slot = start_slot + (SLOTS_PER_EPOCH * epoch_offset); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - current_slot, - lamports_after_write, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, current_slot, lamports_after_write) .unwrap(); assert_eq!( @@ -364,12 +352,7 @@ fn test_default_scenario_16_epochs_with_2_epoch_topup() { // Epoch 16 - should require top-up again let epoch_16_slot = start_slot + (SLOTS_PER_EPOCH * 16); let top_up = extension - .calculate_top_up_lamports( - TEST_BYTES, - epoch_16_slot, - lamports_after_write, - rent_exemption_lamports, - ) + .calculate_top_up_lamports(TEST_BYTES, epoch_16_slot, lamports_after_write) .unwrap(); assert_eq!( diff --git a/program-libs/ctoken-interface/src/constants.rs b/program-libs/ctoken-interface/src/constants.rs index 4ecbae37a8..30f10eeeea 100644 --- a/program-libs/ctoken-interface/src/constants.rs +++ b/program-libs/ctoken-interface/src/constants.rs @@ -7,7 +7,7 @@ pub const CTOKEN_PROGRAM_ID: [u8; 32] = /// Account size constants /// Size of a CToken account with embedded compression info (no extensions). /// CTokenZeroCopy includes: SPL token layout (165) + account_type (1) + decimal_option_prefix (1) -/// + decimals (1) + compression_only (1) + CompressionInfo (88) + has_extensions (1) +/// + decimals (1) + compression_only (1) + CompressionInfo (96) + has_extensions (1) pub use crate::state::BASE_TOKEN_ACCOUNT_SIZE; /// Extension metadata overhead: Vec length (4) - added when any extensions are present diff --git a/program-libs/ctoken-interface/src/state/ctoken/mod.rs b/program-libs/ctoken-interface/src/state/ctoken/mod.rs index 0cc5b7edf4..af14abbcf0 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/mod.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/mod.rs @@ -1,8 +1,10 @@ mod borsh; mod ctoken_struct; mod size; +mod top_up; mod zero_copy; pub use ctoken_struct::*; pub use size::*; +pub use top_up::*; pub use zero_copy::*; diff --git a/program-libs/ctoken-interface/src/state/ctoken/top_up.rs b/program-libs/ctoken-interface/src/state/ctoken/top_up.rs new file mode 100644 index 0000000000..629b148353 --- /dev/null +++ b/program-libs/ctoken-interface/src/state/ctoken/top_up.rs @@ -0,0 +1,80 @@ +//! Optimized top-up lamports calculation for CToken accounts. + +use light_compressible::compression_info::CompressionInfo; +use light_program_profiler::profile; +#[cfg(target_os = "solana")] +use pinocchio::account_info::AccountInfo; + +use super::ACCOUNT_TYPE_TOKEN_ACCOUNT; +use crate::state::ExtensionType; + +/// Minimum size for CToken with Compressible extension as first extension. +/// 176 (offset to CompressionInfo) + 96 (CompressionInfo size) = 272 +pub const MIN_SIZE_WITH_COMPRESSIBLE: usize = COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE; + +/// Offset to CompressionInfo when Compressible is first extension. +/// 165 (base) + 1 (account_type) + 1 (Option) + 4 (Vec len) + 1 (ext disc) + 4 (ext header) = 176 +const COMPRESSION_INFO_OFFSET: usize = 176; + +/// Size of CompressionInfo struct. +/// 2 (config_account_version) + 1 (compress_to_pubkey) + 1 (account_version) + +/// 4 (lamports_per_write) + 32 (compression_authority) + 32 (rent_sponsor) + +/// 8 (last_claimed_slot) + 4 (rent_exemption_paid) + 4 (_reserved) + 8 (rent_config) = 96 +const COMPRESSION_INFO_SIZE: usize = 96; + +/// Offset to account_type field. +const ACCOUNT_TYPE_OFFSET: usize = 165; + +/// Offset to Option discriminator field. +const OPTION_DISCRIMINATOR_OFFSET: usize = 166; + +/// Offset to first extension discriminator. +const FIRST_EXT_DISCRIMINATOR_OFFSET: usize = 171; + +/// Option discriminator value for Some. +const OPTION_SOME: u8 = 1; + +/// Calculate top-up lamports directly from CToken account bytes. +/// Returns None if account doesn't have Compressible extension as first extension. +#[inline(always)] +#[profile] +pub fn top_up_lamports_from_slice( + data: &[u8], + current_lamports: u64, + current_slot: u64, +) -> Option { + if data.len() < MIN_SIZE_WITH_COMPRESSIBLE + || data[ACCOUNT_TYPE_OFFSET] != ACCOUNT_TYPE_TOKEN_ACCOUNT + || data[OPTION_DISCRIMINATOR_OFFSET] != OPTION_SOME + || data[FIRST_EXT_DISCRIMINATOR_OFFSET] != ExtensionType::Compressible as u8 + { + return None; + } + + let info: &CompressionInfo = bytemuck::from_bytes( + &data[COMPRESSION_INFO_OFFSET..COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE], + ); + + info.calculate_top_up_lamports(data.len() as u64, current_slot, current_lamports) + .ok() +} + +/// Calculate top-up lamports from an AccountInfo. +/// Returns None if account doesn't have Compressible extension as first extension. +/// Note: Does not verify account owner. Fetches clock/rent sysvars internally if needed. +/// Pass `current_slot` as 0 to fetch from Clock sysvar; non-zero values are used directly. +#[cfg(target_os = "solana")] +#[inline(always)] +#[profile] +pub fn top_up_lamports_from_account_info_unchecked( + account_info: &AccountInfo, + current_slot: &mut u64, +) -> Option { + use pinocchio::sysvars::{clock::Clock, Sysvar}; + let data = account_info.try_borrow_data().ok()?; + let current_lamports = account_info.lamports(); + if *current_slot == 0 { + *current_slot = Clock::get().ok()?.slot; + } + top_up_lamports_from_slice(&data, current_lamports, *current_slot) +} diff --git a/program-libs/ctoken-interface/src/state/mint/mod.rs b/program-libs/ctoken-interface/src/state/mint/mod.rs index 7684303b0e..63484b75f5 100644 --- a/program-libs/ctoken-interface/src/state/mint/mod.rs +++ b/program-libs/ctoken-interface/src/state/mint/mod.rs @@ -1,6 +1,8 @@ mod borsh; mod compressed_mint; +mod top_up; mod zero_copy; pub use compressed_mint::*; +pub use top_up::*; pub use zero_copy::*; diff --git a/program-libs/ctoken-interface/src/state/mint/top_up.rs b/program-libs/ctoken-interface/src/state/mint/top_up.rs new file mode 100644 index 0000000000..85ddbc04f0 --- /dev/null +++ b/program-libs/ctoken-interface/src/state/mint/top_up.rs @@ -0,0 +1,87 @@ +//! Optimized top-up lamports calculation for CMint accounts. + +use light_compressible::compression_info::CompressionInfo; +use light_program_profiler::profile; +use light_zero_copy::traits::ZeroCopyAt; +#[cfg(target_os = "solana")] +use pinocchio::account_info::AccountInfo; + +use super::compressed_mint::ACCOUNT_TYPE_MINT; + +/// Minimum size for CMint with CompressionInfo. +/// 166 (offset to CompressionInfo) + 96 (CompressionInfo size) = 262 +pub const CMINT_MIN_SIZE_WITH_COMPRESSION: usize = COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE; + +/// Offset to CompressionInfo in CMint. +/// 82 (BaseMint) + 66 (metadata) + 17 (reserved) + 1 (account_type) = 166 +const COMPRESSION_INFO_OFFSET: usize = 166; + +/// Size of CompressionInfo struct (96 bytes). +const COMPRESSION_INFO_SIZE: usize = 96; + +/// Offset to account_type field. +const ACCOUNT_TYPE_OFFSET: usize = 165; + +/// Calculate top-up lamports directly from CMint account bytes. +/// Returns None if account is not a valid CMint. +#[inline(always)] +#[profile] +pub fn cmint_top_up_lamports_from_slice( + data: &[u8], + current_lamports: u64, + current_slot: u64, +) -> Option { + if data.len() < CMINT_MIN_SIZE_WITH_COMPRESSION + || data[ACCOUNT_TYPE_OFFSET] != ACCOUNT_TYPE_MINT + { + return None; + } + + let (info, _) = CompressionInfo::zero_copy_at( + &data[COMPRESSION_INFO_OFFSET..COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE], + ) + .ok()?; + + info.calculate_top_up_lamports(data.len() as u64, current_slot, current_lamports) + .ok() +} + +/// Calculate top-up lamports from a CMint AccountInfo. +/// Verifies account owner matches expected program. Returns None if owner mismatch or invalid. +/// Pass `current_slot` as 0 to fetch from Clock sysvar; non-zero values are used directly. +#[cfg(target_os = "solana")] +#[inline(always)] +#[profile] +pub fn cmint_top_up_lamports_from_account_info( + account_info: &AccountInfo, + current_slot: &mut u64, + program_id: &[u8; 32], +) -> Option { + use pinocchio::sysvars::{clock::Clock, Sysvar}; + + // Check owner matches expected program + if !account_info.is_owned_by(program_id) { + return None; + } + + let data = account_info.try_borrow_data().ok()?; + + if data.len() < CMINT_MIN_SIZE_WITH_COMPRESSION + || data[ACCOUNT_TYPE_OFFSET] != ACCOUNT_TYPE_MINT + { + return None; + } + + let current_lamports = account_info.lamports(); + if *current_slot == 0 { + *current_slot = Clock::get().ok()?.slot; + } + + let (info, _) = CompressionInfo::zero_copy_at( + &data[COMPRESSION_INFO_OFFSET..COMPRESSION_INFO_OFFSET + COMPRESSION_INFO_SIZE], + ) + .ok()?; + + info.calculate_top_up_lamports(data.len() as u64, *current_slot, current_lamports) + .ok() +} diff --git a/program-libs/ctoken-interface/tests/compressed_mint.rs b/program-libs/ctoken-interface/tests/compressed_mint.rs index a4edb1f749..13e2f90a06 100644 --- a/program-libs/ctoken-interface/tests/compressed_mint.rs +++ b/program-libs/ctoken-interface/tests/compressed_mint.rs @@ -2,6 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use light_compressed_account::Pubkey; use light_compressible::compression_info::CompressionInfo; use light_ctoken_interface::state::{ + cmint_top_up_lamports_from_slice, extensions::{AdditionalMetadata, ExtensionStruct, TokenMetadata}, BaseMint, CompressedMint, CompressedMintConfig, CompressedMintMetadata, ACCOUNT_TYPE_MINT, }; @@ -413,3 +414,48 @@ fn test_compressed_mint_new_zero_copy_fails_if_already_initialized() { light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed ); } + +/// Test that cmint_top_up_lamports_from_slice produces identical results to full deserialization. +#[test] +fn test_cmint_top_up_lamports_matches_full_deserialization() { + // Create a CMint using zero-copy + let config = CompressedMintConfig { extensions: None }; + let byte_len = CompressedMint::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; byte_len]; + let (mut cmint, _) = CompressedMint::new_zero_copy(&mut buffer, config).unwrap(); + + // Set known values in CompressionInfo + cmint.base.compression.lamports_per_write = 1000.into(); + cmint.base.compression.last_claimed_slot = 13500.into(); // Epoch 1 + cmint.base.compression.rent_exemption_paid = 50_000.into(); + cmint.base.compression.rent_config.base_rent = 128.into(); + cmint.base.compression.rent_config.compression_cost = 11000.into(); + cmint + .base + .compression + .rent_config + .lamports_per_byte_per_epoch = 1; + cmint.base.compression.rent_config.max_funded_epochs = 2; + + // Test parameters + let current_slot = 27000u64; // Epoch 2 + let current_lamports = 100_000u64; + + // Calculate using optimized function + let optimized_result = + cmint_top_up_lamports_from_slice(&buffer, current_lamports, current_slot) + .expect("Should return Some"); + + // Calculate using full deserialization + let (cmint_read, _) = CompressedMint::zero_copy_at(&buffer).unwrap(); + let full_deser_result = cmint_read + .base + .compression + .calculate_top_up_lamports(buffer.len() as u64, current_slot, current_lamports) + .expect("Should succeed"); + + assert_eq!( + optimized_result, full_deser_result, + "Optimized result should match full deserialization" + ); +} diff --git a/program-libs/ctoken-interface/tests/cross_deserialization.rs b/program-libs/ctoken-interface/tests/cross_deserialization.rs index 1602621156..07705b3fc1 100644 --- a/program-libs/ctoken-interface/tests/cross_deserialization.rs +++ b/program-libs/ctoken-interface/tests/cross_deserialization.rs @@ -41,6 +41,8 @@ fn create_test_cmint() -> CompressedMint { compression_authority: [3u8; 32], rent_sponsor: [4u8; 32], last_claimed_slot: 100, + rent_exemption_paid: 0, + _reserved: 0, rent_config: RentConfig { base_rent: 0, compression_cost: 0, @@ -78,6 +80,8 @@ fn create_test_ctoken_with_extension() -> CToken { compression_authority: [3u8; 32], rent_sponsor: [4u8; 32], last_claimed_slot: 100, + rent_exemption_paid: 0, + _reserved: 0, rent_config: RentConfig { base_rent: 0, compression_cost: 0, diff --git a/program-libs/ctoken-interface/tests/ctoken/mod.rs b/program-libs/ctoken-interface/tests/ctoken/mod.rs index bc3c1fcb23..5ba623dc6d 100644 --- a/program-libs/ctoken-interface/tests/ctoken/mod.rs +++ b/program-libs/ctoken-interface/tests/ctoken/mod.rs @@ -2,4 +2,5 @@ pub mod failing; pub mod randomized_solana_ctoken; pub mod size; pub mod spl_compat; +pub mod top_up; pub mod zero_copy_new; diff --git a/program-libs/ctoken-interface/tests/ctoken/top_up.rs b/program-libs/ctoken-interface/tests/ctoken/top_up.rs new file mode 100644 index 0000000000..f331e8c804 --- /dev/null +++ b/program-libs/ctoken-interface/tests/ctoken/top_up.rs @@ -0,0 +1,77 @@ +//! Test that top_up_lamports_from_slice produces identical results to full deserialization. + +use light_compressed_account::Pubkey; +use light_ctoken_interface::state::{ + top_up_lamports_from_slice, CToken, CompressedTokenConfig, CompressibleExtensionConfig, + CompressionInfoConfig, ExtensionStructConfig, +}; +use light_zero_copy::traits::{ZeroCopyAt, ZeroCopyNew}; + +#[test] +fn test_top_up_lamports_matches_full_deserialization() { + // Create a CToken with Compressible extension + let config = CompressedTokenConfig { + mint: Pubkey::default(), + owner: Pubkey::default(), + state: 1, + extensions: Some(vec![ExtensionStructConfig::Compressible( + CompressibleExtensionConfig { + info: CompressionInfoConfig { rent_config: () }, + }, + )]), + }; + + let size = CToken::byte_len(&config).unwrap(); + let mut buffer = vec![0u8; size]; + let (mut ctoken, _) = CToken::new_zero_copy(&mut buffer, config).unwrap(); + + // Set known values in CompressionInfo via zero-copy + let ext = ctoken.extensions.as_mut().unwrap(); + let compressible = ext + .iter_mut() + .find_map(|e| match e { + light_ctoken_interface::state::ZExtensionStructMut::Compressible(c) => Some(c), + _ => None, + }) + .unwrap(); + + // Set test values + compressible.info.lamports_per_write = 1000.into(); + compressible.info.last_claimed_slot = 13500.into(); // Epoch 1 + compressible.info.rent_exemption_paid = 50_000.into(); + compressible.info.rent_config.base_rent = 128.into(); + compressible.info.rent_config.compression_cost = 11000.into(); + compressible.info.rent_config.lamports_per_byte_per_epoch = 1; + compressible.info.rent_config.max_funded_epochs = 2; + + // Test parameters + let current_slot = 27000u64; // Epoch 2 + let current_lamports = 100_000u64; + + // Calculate using optimized function + let optimized_result = top_up_lamports_from_slice(&buffer, current_lamports, current_slot) + .expect("Should return Some"); + + // Calculate using full deserialization + let (ctoken_read, _) = CToken::zero_copy_at(&buffer).unwrap(); + let compressible_read = ctoken_read + .extensions + .as_ref() + .unwrap() + .iter() + .find_map(|e| match e { + light_ctoken_interface::state::ZExtensionStruct::Compressible(c) => Some(c), + _ => None, + }) + .unwrap(); + + let full_deser_result = compressible_read + .info + .calculate_top_up_lamports(buffer.len() as u64, current_slot, current_lamports) + .expect("Should succeed"); + + assert_eq!( + optimized_result, full_deser_result, + "Optimized result should match full deserialization" + ); +} diff --git a/program-tests/compressed-token-test/tests/compress_only/default_state.rs b/program-tests/compressed-token-test/tests/compress_only/default_state.rs index 7374d63200..918c530776 100644 --- a/program-tests/compressed-token-test/tests/compress_only/default_state.rs +++ b/program-tests/compressed-token-test/tests/compress_only/default_state.rs @@ -62,12 +62,12 @@ async fn test_create_ctoken_with_frozen_default_state() { .await .unwrap(); - // Verify account was created with correct size (266 bytes = 166 base + 7 metadata + 90 compressible + 3 markers) + // Verify account was created with correct size (274 bytes = 166 base + 7 metadata + 98 compressible + 3 markers) let account = rpc.get_account(account_pubkey).await.unwrap().unwrap(); assert_eq!( account.data.len(), - 266, - "CToken account should be 266 bytes" + 274, + "CToken account should be 274 bytes" ); // Deserialize the CToken account using borsh diff --git a/program-tests/utils/src/assert_claim.rs b/program-tests/utils/src/assert_claim.rs index 415189e118..016694fcbf 100644 --- a/program-tests/utils/src/assert_claim.rs +++ b/program-tests/utils/src/assert_claim.rs @@ -35,7 +35,6 @@ fn extract_pre_compression_mut( account_size: u64, current_slot: u64, account_lamports: u64, - base_lamports: u64, pubkey: &Pubkey, ) -> CompressionAssertData { let account_type = determine_account_type(data) @@ -52,8 +51,7 @@ fn extract_pre_compression_mut( let last_claimed_slot = u64::from(compression.last_claimed_slot); let compression_authority = Pubkey::from(compression.compression_authority); let rent_sponsor = Pubkey::from(compression.rent_sponsor); - let lamports_result = - compression.claim(account_size, current_slot, account_lamports, base_lamports); + let lamports_result = compression.claim(account_size, current_slot, account_lamports); let claim_failed = lamports_result.is_err(); let claimable_lamports = lamports_result.ok().flatten(); CompressionAssertData { @@ -71,8 +69,7 @@ fn extract_pre_compression_mut( let last_claimed_slot = u64::from(compression.last_claimed_slot); let compression_authority = Pubkey::from(compression.compression_authority); let rent_sponsor = Pubkey::from(compression.rent_sponsor); - let lamports_result = - compression.claim(account_size, current_slot, account_lamports, base_lamports); + let lamports_result = compression.claim(account_size, current_slot, account_lamports); let claim_failed = lamports_result.is_err(); let claimable_lamports = lamports_result.ok().flatten(); CompressionAssertData { @@ -137,10 +134,6 @@ pub async fn assert_claim( let account_size = pre_token_account.data.len() as u64; let account_lamports = pre_token_account.lamports; let current_slot = rpc.pre_context.as_ref().unwrap().get_sysvar::().slot; - let base_lamports = rpc - .get_minimum_balance_for_rent_exemption(account_size as usize) - .await - .unwrap(); // Extract compression info (handles both CToken and CMint) let pre_data = extract_pre_compression_mut( @@ -148,7 +141,6 @@ pub async fn assert_claim( account_size, current_slot, account_lamports, - base_lamports, token_account_pubkey, ); diff --git a/program-tests/utils/src/assert_close_token_account.rs b/program-tests/utils/src/assert_close_token_account.rs index 3926793af8..b51e494e6d 100644 --- a/program-tests/utils/src/assert_close_token_account.rs +++ b/program-tests/utils/src/assert_close_token_account.rs @@ -124,10 +124,7 @@ async fn assert_compressible_extension( // Calculate expected lamport distribution using the same function as the program let account_size = account_data_before_close.len() as u64; - let base_lamports = rpc - .get_minimum_balance_for_rent_exemption(account_size as usize) - .await - .unwrap(); + let base_lamports: u64 = compression.rent_exemption_paid.into(); // Create AccountRentState and use the method to calculate distribution let state = AccountRentState { diff --git a/program-tests/utils/src/assert_create_token_account.rs b/program-tests/utils/src/assert_create_token_account.rs index 014ad00484..b6b32cc96b 100644 --- a/program-tests/utils/src/assert_create_token_account.rs +++ b/program-tests/utils/src/assert_create_token_account.rs @@ -205,6 +205,8 @@ pub async fn assert_create_token_account_internal( info: CompressionInfo { config_account_version: 1, last_claimed_slot: current_slot, + rent_exemption_paid: rent_exemption as u32, + _reserved: 0, rent_config: RentConfig::default(), lamports_per_write: compressible_info.lamports_per_write.unwrap_or(0), compression_authority: compressible_info.compression_authority.to_bytes(), diff --git a/program-tests/utils/src/assert_ctoken_burn.rs b/program-tests/utils/src/assert_ctoken_burn.rs index 3b8109c36a..34539ba9ec 100644 --- a/program-tests/utils/src/assert_ctoken_burn.rs +++ b/program-tests/utils/src/assert_ctoken_burn.rs @@ -133,22 +133,13 @@ pub async fn assert_ctoken_burn( } async fn calculate_expected_lamport_change( - rpc: &mut LightProgramTest, + _rpc: &mut LightProgramTest, compression: &light_compressible::compression_info::CompressionInfo, data_len: usize, current_slot: u64, current_lamports: u64, ) -> u64 { - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(data_len) - .await - .unwrap(); compression - .calculate_top_up_lamports( - data_len as u64, - current_slot, - current_lamports, - rent_exemption, - ) + .calculate_top_up_lamports(data_len as u64, current_slot, current_lamports) .unwrap() } diff --git a/program-tests/utils/src/assert_ctoken_mint_to.rs b/program-tests/utils/src/assert_ctoken_mint_to.rs index ea784c0a80..f2290f6cd6 100644 --- a/program-tests/utils/src/assert_ctoken_mint_to.rs +++ b/program-tests/utils/src/assert_ctoken_mint_to.rs @@ -133,22 +133,13 @@ pub async fn assert_ctoken_mint_to( } async fn calculate_expected_lamport_change( - rpc: &mut LightProgramTest, + _rpc: &mut LightProgramTest, compression: &light_compressible::compression_info::CompressionInfo, data_len: usize, current_slot: u64, current_lamports: u64, ) -> u64 { - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(data_len) - .await - .unwrap(); compression - .calculate_top_up_lamports( - data_len as u64, - current_slot, - current_lamports, - rent_exemption, - ) + .calculate_top_up_lamports(data_len as u64, current_slot, current_lamports) .unwrap() } diff --git a/program-tests/utils/src/assert_ctoken_transfer.rs b/program-tests/utils/src/assert_ctoken_transfer.rs index aa5bbb1f05..03e757f67b 100644 --- a/program-tests/utils/src/assert_ctoken_transfer.rs +++ b/program-tests/utils/src/assert_ctoken_transfer.rs @@ -74,17 +74,8 @@ pub async fn assert_compressible_for_account( name ); let current_slot = rpc.get_slot().await.unwrap(); - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(data_before.len()) - .await - .unwrap(); let top_up = compression_before - .calculate_top_up_lamports( - data_before.len() as u64, - current_slot, - lamports_before, - rent_exemption, - ) + .calculate_top_up_lamports(data_before.len() as u64, current_slot, lamports_before) .unwrap(); // Check if top-up was applied if top_up != 0 { diff --git a/program-tests/utils/src/assert_mint_action.rs b/program-tests/utils/src/assert_mint_action.rs index c7020749bb..92968374b6 100644 --- a/program-tests/utils/src/assert_mint_action.rs +++ b/program-tests/utils/src/assert_mint_action.rs @@ -268,13 +268,9 @@ pub async fn assert_mint_action( // Calculate expected top-up using embedded compression info let current_slot = rpc.get_slot().await.unwrap(); let account_size = pre_account.data.len() as u64; - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(pre_account.data.len()) - .await - .unwrap(); let expected_top_up = compression_info - .calculate_top_up_lamports(account_size, current_slot, pre_lamports, rent_exemption) + .calculate_top_up_lamports(account_size, current_slot, pre_lamports) .unwrap(); let actual_lamport_change = post_lamports diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index 756097e645..a2961c09bf 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -9,7 +9,7 @@ use light_program_profiler::profile; use pinocchio::{ account_info::AccountInfo, instruction::Seed, - sysvars::{clock::Clock, Sysvar}, + sysvars::{clock::Clock, rent::Rent, Sysvar}, }; use pinocchio_system::instructions::Transfer; use spl_pod::solana_msg::msg; @@ -97,7 +97,7 @@ pub fn process_decompress_mint_action( .map_err(|_| ProgramError::UnsupportedSysvar)? .slot; - // 5. Set compression info on compressed_mint + // 5. Set compression info on compressed_mint (rent_exemption_paid set after account_size calculation) compressed_mint.compression = CompressionInfo { config_account_version: config.version, compress_to_pubkey: 0, // Not applicable for CMint @@ -106,6 +106,8 @@ pub fn process_decompress_mint_action( compression_authority: config.compression_authority.to_bytes(), rent_sponsor: config.rent_sponsor.to_bytes(), last_claimed_slot: current_slot, + rent_exemption_paid: 0, // Updated below after account_size calculation + _reserved: 0, rent_config: config.rent_config, }; @@ -129,6 +131,12 @@ pub fn process_decompress_mint_action( .map_err(|_| ErrorCode::MintActionOutputSerializationFailed)? .len(); + // 7a.1. Store rent exemption at creation (only query Rent sysvar here, never again) + let rent_exemption_paid = Rent::get() + .map_err(|_| ProgramError::UnsupportedSysvar)? + .minimum_balance(account_size) as u32; + compressed_mint.compression.rent_exemption_paid = rent_exemption_paid; + // 7b. Calculate Light Protocol rent (base_rent + bytes * lamports_per_byte * epochs + compression_cost) let light_rent = config .rent_config diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 66a88477a8..175f3ae2d7 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -153,15 +153,21 @@ fn serialize_decompressed_mint( let rent_exemption = get_rent_exemption_lamports(num_bytes).map_err(|_| ErrorCode::CMintRentExemptionFailed)?; - // Start with rent exemption deficit - let mut deficit = rent_exemption.saturating_sub(current_lamports); + // Only update rent_exemption_paid if new rent exemption is higher + // (sponsor should get back what they originally paid) + let rent_exemption_u32 = rent_exemption as u32; + let mut deficit = 0u64; + if rent_exemption_u32 > compressed_mint.compression.rent_exemption_paid { + deficit = (rent_exemption_u32 - compressed_mint.compression.rent_exemption_paid) as u64; + compressed_mint.compression.rent_exemption_paid = rent_exemption_u32; + } // STEP 4: Add compressible top-up if not a fresh decompress if !accounts_config.has_decompress_mint_action { let current_slot = Clock::get().map_err(convert_program_error)?.slot; let top_up = compressed_mint .compression - .calculate_top_up_lamports(num_bytes, current_slot, current_lamports, rent_exemption) + .calculate_top_up_lamports(num_bytes, current_slot, current_lamports) .map_err(|_| ErrorCode::CMintTopUpCalculationFailed)?; // Add compressible top-up to rent deficit deficit = deficit.saturating_add(top_up); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index 4a1b24d5e0..b986707b65 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -76,7 +76,6 @@ pub fn compress_or_decompress_ctokens( &mut current_slot, transfer_amount, lamports_budget, - &mut None, )?; } Ok(()) @@ -104,7 +103,6 @@ pub fn compress_or_decompress_ctokens( &mut current_slot, transfer_amount, lamports_budget, - &mut None, )?; } Ok(()) diff --git a/programs/compressed-token/program/src/ctoken/approve_revoke.rs b/programs/compressed-token/program/src/ctoken/approve_revoke.rs index 4a97187644..8ee40e3db1 100644 --- a/programs/compressed-token/program/src/ctoken/approve_revoke.rs +++ b/programs/compressed-token/program/src/ctoken/approve_revoke.rs @@ -1,13 +1,15 @@ use anchor_lang::solana_program::program_error::ProgramError; -use light_ctoken_interface::{state::CToken, CTokenError}; use pinocchio::account_info::AccountInfo; use pinocchio_token_program::processor::{approve::process_approve, revoke::process_revoke}; - -use crate::shared::{ - compressible_top_up::process_compression_top_up, convert_pinocchio_token_error, - convert_program_error, transfer_lamports_via_cpi, +#[cfg(target_os = "solana")] +use { + crate::shared::{convert_program_error, transfer_lamports_via_cpi}, + light_ctoken_interface::state::top_up_lamports_from_account_info_unchecked, + light_ctoken_interface::CTokenError, }; +use crate::shared::convert_pinocchio_token_error; + /// Approve: 8-byte base (amount), payer at index 2 const APPROVE_BASE_LEN: usize = 8; const APPROVE_PAYER_IDX: usize = 2; @@ -82,50 +84,40 @@ fn handle_compressible_top_up( /// * `BASE_LEN` - Base instruction data length (8 for approve, 0 for revoke) /// * `PAYER_IDX` - Index of payer account (2 for approve, 1 for revoke) #[cold] +#[allow(unused)] fn process_compressible_top_up( account: &AccountInfo, accounts: &[AccountInfo], instruction_data: &[u8], ) -> Result<(), ProgramError> { - let payer = accounts.get(PAYER_IDX); - - let max_top_up = match instruction_data.len() { - len if len == BASE_LEN => 0u16, - len if len == BASE_LEN + 2 => u16::from_le_bytes( - instruction_data[BASE_LEN..BASE_LEN + 2] - .try_into() - .map_err(|_| ProgramError::InvalidInstructionData)?, - ), - _ => return Err(ProgramError::InvalidInstructionData), - }; - - let ctoken = CToken::from_account_info_mut_checked(account)?; - - // Only process top-up if account has Compressible extension - let transfer_amount = if let Some(compressible) = ctoken.get_compressible_extension() { - let mut transfer_amount = 0u64; - - process_compression_top_up( - &compressible.info, - account, - &mut 0, - &mut transfer_amount, - &mut 0, - &mut None, - )?; - - if max_top_up > 0 && (max_top_up as u64) < transfer_amount { - return Err(CTokenError::MaxTopUpExceeded.into()); + // Returns None if no Compressible extension, Some(amount) otherwise + #[cfg(target_os = "solana")] + { + let payer = accounts.get(PAYER_IDX); + + let max_top_up = match instruction_data.len() { + len if len == BASE_LEN => 0u16, + len if len == BASE_LEN + 2 => u16::from_le_bytes( + instruction_data[BASE_LEN..BASE_LEN + 2] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + let transfer_amount = { + let mut current_slot = 0; + top_up_lamports_from_account_info_unchecked(account, &mut current_slot).unwrap_or(0) + }; + + if transfer_amount > 0 { + if max_top_up > 0 && transfer_amount > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + let payer = payer.ok_or(CTokenError::MissingPayer)?; + transfer_lamports_via_cpi(transfer_amount, payer, account) + .map_err(convert_program_error)?; } - transfer_amount - } else { - 0 - }; - - if transfer_amount > 0 { - let payer = payer.ok_or(CTokenError::MissingPayer)?; - transfer_lamports_via_cpi(transfer_amount, payer, account) - .map_err(convert_program_error)?; } Ok(()) diff --git a/programs/compressed-token/program/src/ctoken/burn.rs b/programs/compressed-token/program/src/ctoken/burn.rs index df3d2a9df5..40a30dccdb 100644 --- a/programs/compressed-token/program/src/ctoken/burn.rs +++ b/programs/compressed-token/program/src/ctoken/burn.rs @@ -1,7 +1,6 @@ use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_program_profiler::profile; -use pinocchio::account_info::AccountInfo; -use pinocchio::program_error::ProgramError as PinocchioProgramError; +use pinocchio::{account_info::AccountInfo, program_error::ProgramError as PinocchioProgramError}; use pinocchio_token_program::processor::{burn::process_burn, burn_checked::process_burn_checked}; use crate::shared::{ @@ -86,7 +85,10 @@ pub(crate) fn process_ctoken_supply_change_inner< processor: ProcessorFn, ) -> Result<(), ProgramError> { if accounts.len() < 3 { - msg!("CToken: expected at least 3 accounts received {}", accounts.len()); + msg!( + "CToken: expected at least 3 accounts received {}", + accounts.len() + ); return Err(ProgramError::NotEnoughAccountKeys); } diff --git a/programs/compressed-token/program/src/ctoken/close/processor.rs b/programs/compressed-token/program/src/ctoken/close/processor.rs index 7c5a86ed10..0c8684256d 100644 --- a/programs/compressed-token/program/src/ctoken/close/processor.rs +++ b/programs/compressed-token/program/src/ctoken/close/processor.rs @@ -1,7 +1,7 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::{checks::check_signer, AccountInfoTrait}; -use light_compressible::rent::{get_rent_exemption_lamports, AccountRentState}; +use light_compressible::rent::AccountRentState; use light_ctoken_interface::state::{AccountState, CToken, ZCTokenMut}; use light_program_profiler::profile; #[cfg(target_os = "solana")] @@ -130,9 +130,7 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( let compression_cost: u64 = compression.info.rent_config.compression_cost.into(); let (mut lamports_to_rent_sponsor, mut lamports_to_destination) = { - let base_lamports = - get_rent_exemption_lamports(accounts.token_account.data_len() as u64) - .map_err(|_| ProgramError::InvalidAccountData)?; + let base_lamports: u64 = compression.info.rent_exemption_paid.into(); let state = AccountRentState { num_bytes: accounts.token_account.data_len() as u64, diff --git a/programs/compressed-token/program/src/ctoken/mint_to.rs b/programs/compressed-token/program/src/ctoken/mint_to.rs index 06da59ba68..2a552e5483 100644 --- a/programs/compressed-token/program/src/ctoken/mint_to.rs +++ b/programs/compressed-token/program/src/ctoken/mint_to.rs @@ -5,9 +5,7 @@ use pinocchio_token_program::processor::{ mint_to::process_mint_to, mint_to_checked::process_mint_to_checked, }; -use super::burn::{ - process_ctoken_supply_change_inner, BASE_LEN_CHECKED, BASE_LEN_UNCHECKED, -}; +use super::burn::{process_ctoken_supply_change_inner, BASE_LEN_CHECKED, BASE_LEN_UNCHECKED}; /// Mint account indices: [cmint=0, ctoken=1, authority=2] pub(crate) const MINT_CMINT_IDX: usize = 0; diff --git a/programs/compressed-token/program/src/ctoken/transfer/shared.rs b/programs/compressed-token/program/src/ctoken/transfer/shared.rs index 7b21700507..1d63d8f524 100644 --- a/programs/compressed-token/program/src/ctoken/transfer/shared.rs +++ b/programs/compressed-token/program/src/ctoken/transfer/shared.rs @@ -237,25 +237,16 @@ fn process_account_extensions( // Only calculate top-up if account has Compressible extension if let Some(compression) = token.get_compressible_extension() { // Get current slot for compressible top-up calculation - use pinocchio::sysvars::{clock::Clock, rent::Rent, Sysvar}; + use pinocchio::sysvars::{clock::Clock, Sysvar}; if *current_slot == 0 { *current_slot = Clock::get() .map_err(|_| CTokenError::SysvarAccessError)? .slot; } - let rent_exemption = Rent::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .minimum_balance(account.data_len()); - info.top_up_amount = compression .info - .calculate_top_up_lamports( - account.data_len() as u64, - *current_slot, - account.lamports(), - rent_exemption, - ) + .calculate_top_up_lamports(account.data_len() as u64, *current_slot, account.lamports()) .map_err(|_| CTokenError::InvalidAccountData)?; // Extract cached decimals if set diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index 92f2e35378..8dc9eb956c 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -1,19 +1,20 @@ use anchor_lang::solana_program::program_error::ProgramError; -use light_account_checks::checks::check_owner; -use light_ctoken_interface::{ - state::{CToken, CompressedMint}, - CTokenError, +#[cfg(target_os = "solana")] +use light_ctoken_interface::state::{ + cmint_top_up_lamports_from_account_info, top_up_lamports_from_account_info_unchecked, }; +use light_ctoken_interface::CTokenError; use light_program_profiler::profile; use pinocchio::{ account_info::AccountInfo, - sysvars::{clock::Clock, rent::Rent, Sysvar}, + sysvars::{clock::Clock, Sysvar}, }; use super::{ convert_program_error, transfer_lamports::{multi_transfer_lamports, Transfer}, }; +#[cfg(target_os = "solana")] use crate::LIGHT_CPI_SIGNER; /// Calculate and execute top-up transfers for compressible CMint and CToken accounts. @@ -26,6 +27,7 @@ use crate::LIGHT_CPI_SIGNER; /// * `max_top_up` - Maximum lamports for top-ups combined (0 = no limit) #[inline(always)] #[profile] +#[allow(unused_mut)] pub fn calculate_and_execute_compressible_top_ups<'a>( cmint: &'a AccountInfo, ctoken: &'a AccountInfo, @@ -44,43 +46,30 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( ]; let mut current_slot = 0; - let mut rent: Option = None; // Initialize budget: +1 allows exact match (total == max_top_up) let mut lamports_budget = (max_top_up as u64).saturating_add(1); - // Calculate CMint top-up - { - check_owner(&LIGHT_CPI_SIGNER.program_id, cmint)?; - let cmint_data = cmint.try_borrow_data().map_err(convert_program_error)?; - let (mint, _) = CompressedMint::zero_copy_at_checked(&cmint_data) - .map_err(|_| CTokenError::CMintDeserializationFailed)?; - process_compression_top_up( - &mint.base.compression, - cmint, - &mut current_slot, - &mut transfers[0].amount, - &mut lamports_budget, - &mut rent, - )?; + // Calculate CMint top-up using optimized function (owner check inside) + #[cfg(target_os = "solana")] + if let Some(amount) = cmint_top_up_lamports_from_account_info( + cmint, + &mut current_slot, + &LIGHT_CPI_SIGNER.program_id, + ) { + transfers[0].amount = amount; + lamports_budget = lamports_budget.saturating_sub(amount); } - // Calculate CToken top-up (only if not 165 bytes - 165 means no extensions) - if ctoken.data_len() != 165 { - let token = CToken::from_account_info_checked(ctoken)?; - // Check for Compressible extension - let compressible = token - .get_compressible_extension() - .ok_or::(CTokenError::MissingCompressibleExtension.into())?; - process_compression_top_up( - &compressible.info, - ctoken, - &mut current_slot, - &mut transfers[1].amount, - &mut lamports_budget, - &mut rent, - )?; + // Calculate CToken top-up using optimized function + // Returns None if no Compressible extension (165 bytes or missing extension) + #[cfg(target_os = "solana")] + if let Some(amount) = top_up_lamports_from_account_info_unchecked(ctoken, &mut current_slot) { + transfers[1].amount = amount; + lamports_budget = lamports_budget.saturating_sub(amount); } + #[cfg(not(target_os = "solana"))] + let _ = (cmint, ctoken, &mut current_slot); // Suppress unused warnings // Exit early if no compressible accounts if current_slot == 0 { @@ -101,6 +90,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( } /// Process compression top-up using embedded compression info. +/// Uses stored rent_exemption_paid from CompressionInfo instead of querying Rent sysvar. #[inline(always)] pub fn process_compression_top_up( compression: &T, @@ -108,7 +98,6 @@ pub fn process_compression_top_up, ) -> Result<(), ProgramError> { if *transfer_amount != 0 { return Ok(()); @@ -119,20 +108,12 @@ pub fn process_compression_top_up { @@ -42,6 +40,8 @@ pub struct CompressibleInitData<'a> { pub custom_rent_payer: Option, /// Whether this account is an ATA (determined by instruction path, not ix data) pub is_ata: bool, + /// Rent exemption lamports paid at account creation (from Rent sysvar) + pub rent_exemption_paid: u32, } /// Configuration for initializing a CToken account @@ -78,6 +78,14 @@ pub fn create_compressible_account<'info>( // Calculate account size (includes Compressible extension) let account_size = mint_extensions.calculate_account_size(true)?; + // Get rent exemption from Rent sysvar (only place we query it - store for later use) + #[cfg(target_os = "solana")] + let rent_exemption_paid = Rent::get() + .map_err(|_| ProgramError::UnsupportedSysvar)? + .minimum_balance(account_size as usize) as u32; + #[cfg(not(target_os = "solana"))] + let rent_exemption_paid = 0; + // Calculate rent with compression cost let rent = config_account .rent_config @@ -134,6 +142,7 @@ pub fn create_compressible_account<'info>( None }, is_ata, + rent_exemption_paid, }) } @@ -226,6 +235,7 @@ fn configure_compression_info( config_account, custom_rent_payer, is_ata, + rent_exemption_paid, } = compressible; // Get the Compressible extension (must exist since we added it) @@ -256,6 +266,9 @@ fn configure_compression_info( config_account.rent_config.max_funded_epochs; compressible_ext.info.rent_config.max_top_up = config_account.rent_config.max_top_up.into(); + // Set rent exemption paid at account creation (store once, never query Rent sysvar again) + compressible_ext.info.rent_exemption_paid = rent_exemption_paid.into(); + // Set the compression_authority, rent_sponsor and lamports_per_write compressible_ext.info.compression_authority = config_account.compression_authority.to_bytes(); if let Some(custom_rent_payer) = custom_rent_payer { diff --git a/programs/compressed-token/program/src/shared/transfer_lamports.rs b/programs/compressed-token/program/src/shared/transfer_lamports.rs index f80cd0dbab..f660754da2 100644 --- a/programs/compressed-token/program/src/shared/transfer_lamports.rs +++ b/programs/compressed-token/program/src/shared/transfer_lamports.rs @@ -38,7 +38,7 @@ pub fn transfer_lamports( /// Transfer lamports using CPI to system program /// This is needed when transferring from accounts not owned by our program -#[inline(always)] +#[cold] #[profile] pub fn transfer_lamports_via_cpi( amount: u64, diff --git a/programs/compressed-token/program/tests/mint.rs b/programs/compressed-token/program/tests/mint.rs index 3c7f4e605b..6b5f865ee6 100644 --- a/programs/compressed-token/program/tests/mint.rs +++ b/programs/compressed-token/program/tests/mint.rs @@ -408,6 +408,8 @@ fn test_compressed_mint_borsh_zero_copy_compatibility() { compression_authority: zc.compression_authority, rent_sponsor: zc.rent_sponsor, last_claimed_slot: u64::from(zc.last_claimed_slot), + rent_exemption_paid: u32::from(zc.rent_exemption_paid), + _reserved: u32::from(zc._reserved), rent_config: light_compressible::rent::RentConfig { base_rent: u16::from(zc.rent_config.base_rent), compression_cost: u16::from(zc.rent_config.compression_cost), From 0ed56255e6ea37dc03db4d3e142eb72d9f03d172 Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 10 Jan 2026 21:03:14 +0000 Subject: [PATCH 31/38] fix: js tests --- .../src/v3/layout/layout-mint.ts | 20 ++++++++++++++++++- .../src/v3/layout/layout-transfer2.ts | 4 ++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/js/compressed-token/src/v3/layout/layout-mint.ts b/js/compressed-token/src/v3/layout/layout-mint.ts index 485169b65f..2fbd990592 100644 --- a/js/compressed-token/src/v3/layout/layout-mint.ts +++ b/js/compressed-token/src/v3/layout/layout-mint.ts @@ -157,12 +157,16 @@ export interface CompressionInfo { rentSponsor: PublicKey; /** Last slot rent was claimed */ lastClaimedSlot: bigint; + /** Rent exemption lamports paid at account creation */ + rentExemptionPaid: number; + /** Reserved for future use */ + reserved: number; /** Rent configuration */ rentConfig: RentConfig; } /** Byte length of CompressionInfo */ -export const COMPRESSION_INFO_SIZE = 88; // 2 + 1 + 1 + 4 + 32 + 32 + 8 + 8 +export const COMPRESSION_INFO_SIZE = 96; // 2 + 1 + 1 + 4 + 32 + 32 + 8 + 4 + 4 + 8 /** * Calculate the byte length of a TokenMetadata extension from buffer. @@ -256,6 +260,12 @@ function deserializeCompressionInfo( const lastClaimedSlot = buffer.readBigUInt64LE(offset); offset += 8; + // Read rent_exemption_paid (u32) and _reserved (u32) + const rentExemptionPaid = buffer.readUInt32LE(offset); + offset += 4; + const reserved = buffer.readUInt32LE(offset); + offset += 4; + // Read RentConfig (8 bytes) const baseRent = buffer.readUInt16LE(offset); offset += 2; @@ -284,6 +294,8 @@ function deserializeCompressionInfo( compressionAuthority, rentSponsor, lastClaimedSlot, + rentExemptionPaid, + reserved, rentConfig, }; @@ -416,6 +428,12 @@ function serializeCompressionInfo(compression: CompressionInfo): Buffer { buffer.writeBigUInt64LE(compression.lastClaimedSlot, offset); offset += 8; + // Write rent_exemption_paid (u32) and _reserved (u32) + buffer.writeUInt32LE(compression.rentExemptionPaid, offset); + offset += 4; + buffer.writeUInt32LE(compression.reserved, offset); + offset += 4; + // Write RentConfig (8 bytes) buffer.writeUInt16LE(compression.rentConfig.baseRent, offset); offset += 2; diff --git a/js/compressed-token/src/v3/layout/layout-transfer2.ts b/js/compressed-token/src/v3/layout/layout-transfer2.ts index ad260a64aa..293aee4007 100644 --- a/js/compressed-token/src/v3/layout/layout-transfer2.ts +++ b/js/compressed-token/src/v3/layout/layout-transfer2.ts @@ -190,6 +190,8 @@ const CompressionInfoLayout = struct([ array(u8(), 32, 'compressionAuthority'), array(u8(), 32, 'rentSponsor'), u64('lastClaimedSlot'), + u32('rentExemptionPaid'), + u32('reserved'), RentConfigLayout.replicate('rentConfig'), ]); @@ -255,6 +257,8 @@ function serializeExtensionInstructionData( ), rentSponsor: Array.from(ext.data.rentSponsor.toBytes()), lastClaimedSlot: bn(ext.data.lastClaimedSlot.toString()), + rentExemptionPaid: ext.data.rentExemptionPaid, + reserved: ext.data.reserved, rentConfig: ext.data.rentConfig, }; offset += CompressionInfoLayout.encode(data, buffer, offset); From 05b524245fd5d2d309a03abfef193a15320cecf3 Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 10 Jan 2026 21:07:50 +0000 Subject: [PATCH 32/38] add decompress amount validation + tests --- program-libs/ctoken-interface/src/error.rs | 4 + .../tests/compress_only/ata_decompress.rs | 333 ++++++++++++++++++ .../tests/mint/failing.rs | 86 +++++ .../tests/mint/functional.rs | 117 ++++++ programs/compressed-token/anchor/src/lib.rs | 11 + .../compressed_token/mint_action/accounts.rs | 6 + .../actions/compress_and_close_cmint.rs | 7 +- .../compressed_token/mint_action/processor.rs | 14 +- .../compression/ctoken/compress_and_close.rs | 17 +- .../ctoken/compress_or_decompress_ctokens.rs | 1 + .../compression/ctoken/decompress.rs | 18 + 11 files changed, 605 insertions(+), 9 deletions(-) diff --git a/program-libs/ctoken-interface/src/error.rs b/program-libs/ctoken-interface/src/error.rs index 8eeeee1483..ff31f8a422 100644 --- a/program-libs/ctoken-interface/src/error.rs +++ b/program-libs/ctoken-interface/src/error.rs @@ -197,6 +197,9 @@ pub enum CTokenError { #[error("CToken account has invalid owner")] InvalidCTokenOwner, + + #[error("Decompress amount mismatch between compression instruction and input token data")] + DecompressAmountMismatch, } impl From for u32 { @@ -265,6 +268,7 @@ impl From for u32 { CTokenError::MissingPayer => 18061, CTokenError::BorrowFailed => 18062, CTokenError::InvalidCTokenOwner => 18063, + CTokenError::DecompressAmountMismatch => 18064, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index c7adddb3b2..700957af9b 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -781,6 +781,339 @@ async fn test_decompress_skips_delegate_if_destination_has_delegate() { ); } +/// Test that multiple compress-decompress cycles work correctly for the same ATA. +/// Creates the same ATA twice, each time compressing it, then decompresses both +/// compressed accounts back to the ATA in a single Transfer2 instruction. +#[tokio::test] +#[serial] +async fn test_ata_multiple_compress_decompress_cycles() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + // Create mint with Pausable extension (restricted, requires compression_only) + let extensions = &[ExtensionType::Pausable]; + let (mint_keypair, _) = + create_mint_22_with_extension_types(&mut rpc, &payer, 9, extensions).await; + let mint_pubkey = mint_keypair.pubkey(); + + // Create SPL Token-2022 account and mint tokens for funding + let spl_account = + create_token_22_account(&mut rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let total_mint_amount = 10_000_000_000u64; + mint_spl_tokens_22( + &mut rpc, + &payer, + &mint_pubkey, + &spl_account, + total_mint_amount, + ) + .await; + + // Setup wallet owner and derive ATA + let wallet = Keypair::new(); + let (ata_pubkey, ata_bump) = derive_ctoken_ata(&wallet.pubkey(), &mint_pubkey); + + let amount1 = 100_000_000u64; + let amount2 = 200_000_000u64; + + // ========== CYCLE 1 ========== + println!("=== Cycle 1: Create ATA, fund, compress ==="); + + // Create ATA with compression_only=true + let create_ata_ix = + CreateAssociatedCTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(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, // Immediately compressible + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Transfer tokens from SPL to ATA + let has_restricted = extensions + .iter() + .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted); + + let transfer_ix1 = TransferSplToCtoken { + amount: amount1, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination_ctoken_account: ata_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix1], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Warp to trigger compression + rpc.warp_epoch_forward(30).await.unwrap(); + + // Verify ATA is closed + let ata_after_cycle1 = rpc.get_account(ata_pubkey).await.unwrap(); + assert!( + ata_after_cycle1.is_none(), + "ATA should be closed after cycle 1 compression" + ); + + // ========== CYCLE 2 ========== + println!("=== Cycle 2: Create ATA again, fund, compress ==="); + + // Create ATA again (same address) + let create_ata_ix2 = + CreateAssociatedCTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(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, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix2], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Transfer tokens from SPL to ATA + let transfer_ix2 = TransferSplToCtoken { + amount: amount2, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination_ctoken_account: ata_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix2], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Warp to trigger compression + rpc.warp_epoch_forward(30).await.unwrap(); + + // Verify ATA is closed again + let ata_after_cycle2 = rpc.get_account(ata_pubkey).await.unwrap(); + assert!( + ata_after_cycle2.is_none(), + "ATA should be closed after cycle 2 compression" + ); + + // ========== VERIFY COMPRESSED ACCOUNTS ========== + println!("=== Verifying compressed accounts ==="); + + // For ATAs with compression_only=true, the compressed account owner is the ATA pubkey + let compressed_accounts = rpc + .get_compressed_token_accounts_by_owner(&ata_pubkey, None, None) + .await + .unwrap() + .value + .items; + + assert_eq!( + compressed_accounts.len(), + 2, + "Should have 2 compressed token accounts (one from each cycle)" + ); + + // Verify both have CompressedOnly extension with is_ata=1 + for (i, account) in compressed_accounts.iter().enumerate() { + let has_compressed_only_with_is_ata = account + .token + .tlv + .as_ref() + .map(|tlv| { + tlv.iter() + .any(|ext| matches!(ext, ExtensionStruct::CompressedOnly(e) if e.is_ata == 1)) + }) + .unwrap_or(false); + + assert!( + has_compressed_only_with_is_ata, + "Compressed account {} should have CompressedOnly extension with is_ata=1", + i + ); + + // Verify owner is ATA pubkey + let owner_bytes: [u8; 32] = account.token.owner.to_bytes(); + assert_eq!( + owner_bytes, + ata_pubkey.to_bytes(), + "Compressed account {} owner should be ATA pubkey", + i + ); + } + + // Verify amounts + let amounts: Vec = compressed_accounts.iter().map(|a| a.token.amount).collect(); + assert!( + amounts.contains(&amount1) && amounts.contains(&amount2), + "Should have compressed accounts with amounts {} and {}, got {:?}", + amount1, + amount2, + amounts + ); + + // ========== DECOMPRESS BOTH ========== + println!("=== Decompressing both to same ATA ==="); + + // Create ATA again (destination for decompress) + let create_ata_ix3 = + CreateAssociatedCTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(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: 2, // More epochs so it won't be compressed immediately + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .idempotent() + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix3], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Build Transfer2 with TWO Decompress operations to the same ATA + // Each decompress needs a unique compression_index + let in_tlv1 = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, // First decompress + is_ata: true, + bump: ata_bump, + owner_index: 0, // Will be updated by create_generic_transfer2_instruction + }, + )]]; + + let in_tlv2 = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 1, // Second decompress - different index + is_ata: true, + bump: ata_bump, + owner_index: 0, // Will be updated by create_generic_transfer2_instruction + }, + )]]; + + let decompress_ix = create_generic_transfer2_instruction( + &mut rpc, + vec![ + Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![compressed_accounts[0].clone()], + decompress_amount: compressed_accounts[0].token.amount, + solana_token_account: ata_pubkey, + amount: compressed_accounts[0].token.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv1), + }), + Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![compressed_accounts[1].clone()], + decompress_amount: compressed_accounts[1].token.amount, + solana_token_account: ata_pubkey, + amount: compressed_accounts[1].token.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv2), + }), + ], + payer.pubkey(), + true, + ) + .await + .unwrap(); + + // For ATA decompress, wallet owner signs (not ATA pubkey) + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer, &wallet]) + .await + .unwrap(); + + // ========== VERIFY FINAL STATE ========== + println!("=== Verifying final state ==="); + + // Verify ATA has combined balance + use borsh::BorshDeserialize; + use light_ctoken_interface::state::CToken; + + let ata_account = rpc.get_account(ata_pubkey).await.unwrap().unwrap(); + let ata_ctoken = CToken::deserialize(&mut &ata_account.data[..]).unwrap(); + + assert_eq!( + ata_ctoken.amount, + amount1 + amount2, + "ATA should have combined balance of {} + {} = {}, got {}", + amount1, + amount2, + amount1 + amount2, + ata_ctoken.amount + ); + + // Verify no more compressed token accounts + let remaining = rpc + .get_compressed_token_accounts_by_owner(&ata_pubkey, None, None) + .await + .unwrap() + .value + .items; + + assert!( + remaining.is_empty(), + "All compressed accounts should be consumed, got {} remaining", + remaining.len() + ); + + println!( + "Successfully completed ATA multiple compress-decompress cycles test. Final balance: {}", + ata_ctoken.amount + ); +} + /// Test that non-ATA CompressOnly decompress keeps current owner-match behavior. #[tokio::test] #[serial] diff --git a/program-tests/compressed-token-test/tests/mint/failing.rs b/program-tests/compressed-token-test/tests/mint/failing.rs index 0ba559c05b..d4fdf2d5a7 100644 --- a/program-tests/compressed-token-test/tests/mint/failing.rs +++ b/program-tests/compressed-token-test/tests/mint/failing.rs @@ -1019,3 +1019,89 @@ async fn test_create_mint_non_signer_mint_signer() { ) .unwrap(); } + +/// Test that CompressAndCloseCMint must be the only action in the instruction. +/// Attempting to combine CompressAndCloseCMint with UpdateMintAuthority should fail. +#[tokio::test] +#[serial] +async fn test_compress_and_close_cmint_must_be_only_action() { + use light_compressible::rent::SLOTS_PER_EPOCH; + use light_ctoken_sdk::compressed_token::create_compressed_mint::derive_cmint_compressed_address; + use light_program_test::program_test::TestRpc; + use light_token_client::instructions::mint_action::DecompressMintParams; + + 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(), 1_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + + // 1. Create compressed mint with CMint (decompressed) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + Some(light_token_client::instructions::mint_action::NewMint { + decimals: 9, + supply: 0, + mint_authority: mint_authority.pubkey(), + freeze_authority: None, + metadata: None, + version: 3, + }), + ) + .await + .unwrap(); + + // Warp to epoch 2 so that rent expires + rpc.warp_to_slot(SLOTS_PER_EPOCH * 2).unwrap(); + + // 2. Try to combine CompressAndCloseCMint with UpdateMintAuthority + let new_authority = Keypair::new(); + let result = light_token_client::actions::mint_action( + &mut rpc, + light_token_client::instructions::mint_action::MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: mint_authority.pubkey(), + payer: payer.pubkey(), + actions: vec![ + MintActionType::CompressAndCloseCMint { idempotent: false }, + MintActionType::UpdateMintAuthority { + new_authority: Some(new_authority.pubkey()), + }, + ], + new_mint: None, + }, + &mint_authority, + &payer, + None, + ) + .await; + + // Should fail with CompressAndCloseCMintMustBeOnlyAction (error code 6169) + assert_rpc_error( + result, 0, 6169, // CompressAndCloseCMintMustBeOnlyAction + ) + .unwrap(); +} diff --git a/program-tests/compressed-token-test/tests/mint/functional.rs b/program-tests/compressed-token-test/tests/mint/functional.rs index 9801e5e5f0..a7da7c01c8 100644 --- a/program-tests/compressed-token-test/tests/mint/functional.rs +++ b/program-tests/compressed-token-test/tests/mint/functional.rs @@ -1530,6 +1530,123 @@ async fn test_create_compressed_mint_with_cmint() { println!("CompressAndCloseCMint test completed successfully!"); } +/// Test idempotent behavior of CompressAndCloseCMint. +/// When CMint is already compressed, calling with idempotent=true should succeed silently. +#[tokio::test] +#[serial] +async fn test_compress_and_close_cmint_idempotent() { + use light_program_test::program_test::TestRpc; + + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let decimals = 9u8; + let mint_seed = Keypair::new(); + let mint_authority = Keypair::new(); + let freeze_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_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (cmint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint WITH CMint (decompress_mint = true) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + Some(light_token_client::instructions::mint_action::NewMint { + decimals, + supply: 0, + mint_authority: mint_authority.pubkey(), + freeze_authority: Some(freeze_authority.pubkey()), + metadata: None, + version: 3, + }), + ) + .await + .unwrap(); + + // Warp to epoch 2 so that rent expires + rpc.warp_to_slot(SLOTS_PER_EPOCH * 2).unwrap(); + + // 2. Compress and close CMint (first time - should succeed) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &mint_authority, + &payer, + None, + true, // compress_and_close_cmint = true + vec![], + vec![], + None, + None, + None, + ) + .await + .unwrap(); + + // Verify CMint is closed + let cmint_after_close = rpc.get_account(cmint_pda).await.unwrap(); + assert!( + cmint_after_close.is_none(), + "CMint should be closed after CompressAndCloseCMint" + ); + + // 3. Try CompressAndCloseCMint again with idempotent=true (should succeed silently) + // Use a very low compute budget (10k) to verify the CPI is being skipped. + // If CPI was executed, this would fail due to insufficient compute units. + use light_client::rpc::Rpc; + use solana_sdk::compute_budget::ComputeBudgetInstruction; + + let mint_action_ix = + light_token_client::instructions::mint_action::create_mint_action_instruction( + &mut rpc, + light_token_client::instructions::mint_action::MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: mint_authority.pubkey(), + payer: payer.pubkey(), + actions: vec![MintActionType::CompressAndCloseCMint { idempotent: true }], + new_mint: None, + }, + ) + .await + .unwrap(); + + let compute_budget_ix = ComputeBudgetInstruction::set_compute_unit_limit(10_000); + + let result = rpc + .create_and_send_transaction( + &[compute_budget_ix, mint_action_ix], + &payer.pubkey(), + &[&payer, &mint_authority], + ) + .await; + + assert!( + result.is_ok(), + "CompressAndCloseCMint with idempotent=true should succeed with only 10k compute units when CPI is skipped: {:?}", + result.err() + ); + + println!("CompressAndCloseCMint idempotent test completed successfully!"); +} + /// Test decompressing an existing compressed mint to CMint /// 1. Create compressed mint without CMint /// 2. Mint tokens to recipients diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index 6132f0cdb3..81f090543e 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -582,6 +582,12 @@ pub enum ErrorCode { MintDecimalsMismatch, // 6166 (SPL Token code 18) #[msg("Failed to calculate rent exemption for CMint")] CMintRentExemptionFailed, // 6167 + #[msg("CompressAndClose: is_ata mismatch between CompressibleExtension and CompressedOnly extension")] + CompressAndCloseIsAtaMismatch, // 6168 + #[msg("CompressAndCloseCMint must be the only action in the instruction")] + CompressAndCloseCMintMustBeOnlyAction, // 6169 + #[msg("Idempotent early exit - not a real error, used to skip CPI")] + IdempotentEarlyExit, // 6170 } /// Anchor error code offset - error codes start at 6000 @@ -593,6 +599,11 @@ impl From for ProgramError { } } +/// Checks if an error is the IdempotentEarlyExit error (used to skip CPI) +pub fn is_idempotent_early_exit(err: &ProgramError) -> bool { + matches!(err, ProgramError::Custom(code) if *code == ERROR_CODE_OFFSET + ErrorCode::IdempotentEarlyExit as u32) +} + /// Checks if CPI context usage is valid for the current instruction /// Throws an error if cpi_context is Some and (set_context OR first_set_context is true) pub fn check_cpi_context(cpi_context: &Option) -> Result<()> { diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index 337e0604db..888dbf4dd1 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -438,6 +438,12 @@ impl AccountsConfig { return Err(ErrorCode::CannotDecompressAndCloseInSameInstruction.into()); } + // Validation: CompressAndCloseCMint must be the only action + if has_compress_and_close_cmint_action && parsed_instruction_data.actions.len() != 1 { + msg!("CompressAndCloseCMint must be the only action in the instruction"); + return Err(ErrorCode::CompressAndCloseCMintMustBeOnlyAction.into()); + } + // We need mint signer if create mint or decompress mint. // CompressAndCloseCMint does NOT need mint_signer - it verifies CMint by compressed_mint.metadata.mint let with_mint_signer = diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs index 549bb5126f..9ec9ddf8b9 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/compress_and_close_cmint.rs @@ -40,10 +40,9 @@ pub fn process_compress_and_close_cmint_action( ) -> Result<(), ProgramError> { // NOTE: CompressAndCloseCMint is permissionless - anyone can compress if is_compressible() returns true // All lamports returned to rent_sponsor - // TODO: test idempotent, it should exit the complete instruction silently. - // 1. Idempotent check - if CMint doesn't exist and idempotent is set, succeed silently + // 1. Idempotent check - if CMint doesn't exist and idempotent is set, return early exit error to skip CPI if action.is_idempotent() && !compressed_mint.metadata.cmint_decompressed { - return Ok(()); + return Err(ErrorCode::IdempotentEarlyExit.into()); } // 2. Check CMint exists (is decompressed) @@ -91,7 +90,7 @@ pub fn process_compress_and_close_cmint_action( if is_compressible.is_none() { if action.is_idempotent() { - return Ok(()); + return Err(ErrorCode::IdempotentEarlyExit.into()); } msg!("CMint is not compressible (rent not expired)"); return Err(ErrorCode::CMintNotCompressible.into()); diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs index 1139279963..8add5d0b36 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs @@ -1,4 +1,4 @@ -use anchor_compressed_token::ErrorCode; +use anchor_compressed_token::{is_idempotent_early_exit, ErrorCode}; use anchor_lang::prelude::ProgramError; use light_compressed_account::instruction_data::with_readonly::InstructionDataInvokeCpiWithReadOnly; use light_ctoken_interface::{ @@ -135,7 +135,7 @@ pub fn process_mint_action( )?; }; - process_output_compressed_account( + let result = process_output_compressed_account( &parsed_instruction_data, &validated_accounts, &mut cpi_instruction_struct.output_compressed_accounts, @@ -143,7 +143,15 @@ pub fn process_mint_action( &queue_indices, mint, &accounts_config, - )?; + ); + + // Check for idempotent early exit - skip CPI and return success + if let Err(ref err) = result { + if is_idempotent_early_exit(err) { + return Ok(()); + } + } + result?; let cpi_accounts = validated_accounts.get_cpi_accounts(queue_indices.deduplicated, accounts)?; if let Some(executing) = validated_accounts.executing.as_ref() { diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 0f902ef150..025d3259ee 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -87,6 +87,7 @@ pub fn process_compress_and_close( /// 7b. Delegate pubkey must match (if present) /// 7c. Withheld fee must match /// 7d. Frozen state must match +/// 7e. is_ata must match fn validate_compressed_token_account( packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, compression_amount: u64, @@ -154,8 +155,14 @@ fn validate_compressed_token_account( return Ok(()); }; - // 7. With extension: validate delegate, withheld_fee, frozen - validate_compressed_only_ext(packed_accounts, compressed_token_account, ctoken, ext) + // 7. With extension: validate delegate, withheld_fee, frozen, is_ata + validate_compressed_only_ext( + packed_accounts, + compressed_token_account, + ctoken, + ext, + compression, + ) } /// Validate CompressedOnly extension fields match ctoken state. @@ -165,6 +172,7 @@ fn validate_compressed_only_ext( compressed_token_account: &ZMultiTokenTransferOutputData<'_>, ctoken: &ZCTokenMut, ext: &light_ctoken_interface::instructions::extensions::compressed_only::ZCompressedOnlyExtensionInstructionData, + compression: &light_ctoken_interface::state::ZCompressibleExtensionMut<'_>, ) -> Result<(), ProgramError> { // 7a. Delegated amount must match let ext_delegated: u64 = ext.delegated_amount.into(); @@ -205,6 +213,11 @@ fn validate_compressed_only_ext( return Err(ErrorCode::CompressAndCloseFrozenMismatch.into()); } + // 7e. is_ata must match + if compression.is_ata() != ext.is_ata() { + return Err(ErrorCode::CompressAndCloseIsAtaMismatch.into()); + } + Ok(()) } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index b986707b65..dc545ba397 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -86,6 +86,7 @@ pub fn compress_or_decompress_ctokens( &mut ctoken, decompress_inputs, packed_accounts, + amount, )?; // Decompress: add to CToken account diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs index 21fb0c6109..c9c518392b 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -18,6 +18,7 @@ pub fn apply_decompress_extension_state( ctoken: &mut ZCTokenMut, decompress_inputs: Option, packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + compression_amount: u64, ) -> Result<(), ProgramError> { let Some(inputs) = decompress_inputs else { return Ok(()); @@ -27,6 +28,23 @@ pub fn apply_decompress_extension_state( return Ok(()); }; + // === VALIDATE amount matches for ATA or compress_to_pubkey decompress === + let compress_to_pubkey = ctoken + .get_compressible_extension() + .map(|ext| ext.info.compress_to_pubkey()) + .unwrap_or(false); + if ext_data.is_ata() || compress_to_pubkey { + let input_amount: u64 = inputs.input_token_data.amount.into(); + if compression_amount != input_amount { + msg!( + "Decompress: amount mismatch (compression: {}, input: {})", + compression_amount, + input_amount + ); + return Err(CTokenError::DecompressAmountMismatch.into()); + } + } + // === VALIDATE destination ownership === let input_owner = packed_accounts.get_u8(inputs.input_token_data.owner, "input owner")?; validate_destination( From 39b9adfb2a9b2ad287c7ed7a83ff41ddf6d027d0 Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 10 Jan 2026 21:26:01 +0000 Subject: [PATCH 33/38] add outof bounds check, add test_ata_decompress_with_mismatched_amount_fails --- program-libs/ctoken-interface/src/error.rs | 4 + .../tests/compress_only/ata_decompress.rs | 194 ++++++++++++++++++ .../transfer2/token_inputs.rs | 5 +- 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/program-libs/ctoken-interface/src/error.rs b/program-libs/ctoken-interface/src/error.rs index ff31f8a422..232d741353 100644 --- a/program-libs/ctoken-interface/src/error.rs +++ b/program-libs/ctoken-interface/src/error.rs @@ -200,6 +200,9 @@ pub enum CTokenError { #[error("Decompress amount mismatch between compression instruction and input token data")] DecompressAmountMismatch, + + #[error("Compression index exceeds maximum allowed value")] + CompressionIndexOutOfBounds, } impl From for u32 { @@ -269,6 +272,7 @@ impl From for u32 { CTokenError::BorrowFailed => 18062, CTokenError::InvalidCTokenOwner => 18063, CTokenError::DecompressAmountMismatch => 18064, + CTokenError::CompressionIndexOutOfBounds => 18065, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index 700957af9b..9f3f029194 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -38,6 +38,8 @@ use super::shared::{set_ctoken_account_state, setup_extensions_test}; const DECOMPRESS_DESTINATION_MISMATCH: u32 = 18057; /// Expected error code for MintMismatch const MINT_MISMATCH: u32 = 18058; +/// Expected error code for DecompressAmountMismatch +const DECOMPRESS_AMOUNT_MISMATCH: u32 = 18064; /// Setup context for ATA CompressOnly tests struct AtaCompressedTokenContext { @@ -781,6 +783,198 @@ async fn test_decompress_skips_delegate_if_destination_has_delegate() { ); } +/// Test that decompress with mismatched amount fails for ATA. +/// The compression_amount in the instruction must match the input token data amount. +#[tokio::test] +#[serial] +async fn test_ata_decompress_with_mismatched_amount_fails() { + use borsh::BorshSerialize; + use light_compressed_account::compressed_account::PackedMerkleContext; + use light_ctoken_interface::instructions::transfer2::{ + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, + MultiInputTokenDataWithContext, + }; + use light_ctoken_interface::TRANSFER2; + use light_ctoken_sdk::compressed_token::transfer2::account_metas::{ + get_transfer2_instruction_account_metas, Transfer2AccountsMetaConfig, + }; + use light_sdk::instruction::PackedAccounts; + use solana_sdk::instruction::Instruction; + + let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + .await + .unwrap(); + + // Create destination ATA + let create_dest_ix = CreateAssociatedCTokenAccount::new( + context.payer.pubkey(), + context.owner.pubkey(), + context.mint_pubkey, + ) + .with_compressible(CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }) + .idempotent() + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &context.payer.pubkey(), + &[&context.payer], + ) + .await + .unwrap(); + + // Build instruction data directly to control compressions without SDK adding change outputs + let compressed_account = &context.compressed_account; + let mut packed_accounts = PackedAccounts::default(); + + // Add merkle tree and output queue + let merkle_tree = compressed_account.account.tree_info.tree; + let queue = compressed_account.account.tree_info.queue; + let tree_index = packed_accounts.insert_or_get(merkle_tree); + let queue_index = packed_accounts.insert_or_get(queue); + + // Add mint and owner + let mint_index = packed_accounts.insert_or_get_read_only(compressed_account.token.mint); + let owner_index = packed_accounts.insert_or_get_config(context.owner.pubkey(), true, false); + + // Add CToken ATA recipient account + let ctoken_ata_index = packed_accounts.insert_or_get_config(context.ata_pubkey, false, true); + + // Create input token data with FULL amount (what merkle proof verifies) + let has_delegate = compressed_account.token.delegate.is_some(); + let delegate_index = if has_delegate { + packed_accounts + .insert_or_get_read_only(compressed_account.token.delegate.unwrap_or_default()) + } else { + 0 + }; + + let input_token_data = vec![MultiInputTokenDataWithContext { + owner: owner_index, + amount: compressed_account.token.amount, // Full amount for merkle proof + has_delegate, + delegate: delegate_index, + mint: mint_index, + version: 3, // ShaFlat + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_index, + queue_pubkey_index: queue_index, + leaf_index: compressed_account.account.leaf_index, + prove_by_index: true, + }, + root_index: 0, + }]; + + // Create compression with WRONG amount (mismatch!) + // Input has full amount but compression claims only half + let wrong_decompress_amount = context.amount / 2; + let compressions = vec![ + Compression { + mode: CompressionMode::Decompress, + amount: wrong_decompress_amount, // WRONG: doesn't match input amount + mint: mint_index, + source_or_recipient: ctoken_ata_index, + authority: 0, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 9, + }, + Compression { + mode: CompressionMode::Decompress, + amount: wrong_decompress_amount, // WRONG: doesn't match input amount + mint: mint_index, + source_or_recipient: ctoken_ata_index, + authority: 0, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 9, + }, + ]; + + // Build in_tlv for CompressedOnly extension + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: context.ata_bump, + owner_index, + }, + )]]; + + // Build instruction data directly + 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: queue_index, + proof: + light_compressed_account::instruction_data::compressed_proof::ValidityProof::default() + .into(), + in_token_data: input_token_data, + out_token_data: vec![], // No compressed outputs + in_lamports: None, + out_lamports: None, + in_tlv: Some(in_tlv), + out_tlv: None, + compressions: Some(compressions), + cpi_context: None, + max_top_up: 0, + }; + + // Serialize instruction data + let serialized = instruction_data.try_to_vec().unwrap(); + let mut data = Vec::with_capacity(1 + serialized.len()); + data.push(TRANSFER2); + data.extend(serialized); + + // Get account metas + let (account_metas, _, _) = packed_accounts.to_account_metas(); + let meta_config = Transfer2AccountsMetaConfig::new(context.payer.pubkey(), account_metas); + let instruction_account_metas = get_transfer2_instruction_account_metas(meta_config); + + let decompress_ix = Instruction { + program_id: light_ctoken_interface::CTOKEN_PROGRAM_ID.into(), + accounts: instruction_account_metas, + data, + }; + + let result = context + .rpc + .create_and_send_transaction( + &[decompress_ix], + &context.payer.pubkey(), + &[&context.payer, &context.owner], + ) + .await; + + assert_rpc_error(result, 0, DECOMPRESS_AMOUNT_MISMATCH).unwrap(); +} + /// Test that multiple compress-decompress cycles work correctly for the same ATA. /// Creates the same ATA twice, each time compressing it, then decompresses both /// compressed accounts back to the ATA in a single Transfer2 instruction. diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs index 944084c193..26df5b35d7 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs @@ -53,7 +53,10 @@ pub fn set_input_compressed_accounts<'a>( for ext in tlv { if let ZExtensionInstructionData::CompressedOnly(co) = ext { let idx = co.compression_index as usize; - // TODO check that it is not out of bounds + // Check bounds before array access + if idx >= MAX_COMPRESSIONS { + return Err(CTokenError::CompressionIndexOutOfBounds.into()); + } // Check uniqueness - error if compression_index already used if compression_to_input[idx].is_some() { return Err(CTokenError::DuplicateCompressionIndex.into()); From 6a798a7595953c21973b7fcb05eed3144efc530c Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 10 Jan 2026 22:52:49 +0000 Subject: [PATCH 34/38] fix: explicit error on ATA derivation failure in decompress - Rename apply_decompress_extension_state to validate_and_apply_compressed_only - Add InvalidAtaDerivation error code (18066) for explicit ATA validation failures - Extract resolve_ata_signer cold function with explicit error handling - Extract convert_tlv_to_extension_structs cold function out of hot path - Fix test_ata_decompress_with_mismatched_amount_fails to use correct owner setup Previously, ATA derivation failures silently fell back to owner_account, relying on the implicit property that ATAs cannot sign. Now returns explicit InvalidAtaDerivation error for better debugging and security. --- program-libs/ctoken-interface/src/error.rs | 4 + .../tests/compress_only/ata_decompress.rs | 23 +-- programs/compressed-token/program/CLAUDE.md | 39 +++-- .../compressed-token/program/docs/ACCOUNTS.md | 60 ++++++-- .../compressed-token/program/docs/CLAUDE.md | 1 - .../program/docs/EXTENSIONS.md | 123 ++++++++------- .../program/docs/INSTRUCTIONS.md | 59 ++++--- .../program/docs/T22_VS_CTOKEN_COMPARISON.md | 38 +++-- .../compressed_token/CREATE_TOKEN_POOL.md | 2 +- .../program/docs/compressed_token/FREEZE.md | 2 +- .../docs/compressed_token/MINT_ACTION.md | 102 ++++++++----- .../docs/compressed_token/TRANSFER2.md | 81 +++++----- .../program/docs/compressible/CLAIM.md | 98 ++++++------ .../compressible/WITHDRAW_FUNDING_POOL.md | 17 ++- .../program/docs/ctoken/APPROVE.md | 93 ++++++----- .../program/docs/ctoken/APPROVE_CHECKED.md | 120 --------------- .../program/docs/ctoken/BURN.md | 36 +++-- .../program/docs/ctoken/BURN_CHECKED.md | 52 ++++--- .../program/docs/ctoken/CLOSE.md | 68 +++++---- .../program/docs/ctoken/CREATE.md | 39 ++--- .../program/docs/ctoken/FREEZE_ACCOUNT.md | 14 +- .../program/docs/ctoken/MINT_TO.md | 21 ++- .../program/docs/ctoken/MINT_TO_CHECKED.md | 30 ++-- .../program/docs/ctoken/REVOKE.md | 62 ++++---- .../program/docs/ctoken/THAW_ACCOUNT.md | 68 +++++---- .../program/docs/ctoken/TRANSFER.md | 37 +++-- .../program/docs/ctoken/TRANSFER_CHECKED.md | 34 +++-- .../ctoken/compress_or_decompress_ctokens.rs | 4 +- .../compression/ctoken/decompress.rs | 4 +- .../program/src/shared/token_input.rs | 144 +++++++++++------- 30 files changed, 752 insertions(+), 723 deletions(-) delete mode 100644 programs/compressed-token/program/docs/ctoken/APPROVE_CHECKED.md diff --git a/program-libs/ctoken-interface/src/error.rs b/program-libs/ctoken-interface/src/error.rs index 232d741353..4d5983b55d 100644 --- a/program-libs/ctoken-interface/src/error.rs +++ b/program-libs/ctoken-interface/src/error.rs @@ -203,6 +203,9 @@ pub enum CTokenError { #[error("Compression index exceeds maximum allowed value")] CompressionIndexOutOfBounds, + + #[error("ATA derivation failed or mismatched for is_ata compressed token")] + InvalidAtaDerivation, } impl From for u32 { @@ -273,6 +276,7 @@ impl From for u32 { CTokenError::InvalidCTokenOwner => 18063, CTokenError::DecompressAmountMismatch => 18064, CTokenError::CompressionIndexOutOfBounds => 18065, + CTokenError::InvalidAtaDerivation => 18066, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index 9f3f029194..6a08faa6d4 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -790,11 +790,13 @@ async fn test_decompress_skips_delegate_if_destination_has_delegate() { async fn test_ata_decompress_with_mismatched_amount_fails() { use borsh::BorshSerialize; use light_compressed_account::compressed_account::PackedMerkleContext; - use light_ctoken_interface::instructions::transfer2::{ - CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, - MultiInputTokenDataWithContext, + use light_ctoken_interface::{ + instructions::transfer2::{ + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, + MultiInputTokenDataWithContext, + }, + TRANSFER2, }; - use light_ctoken_interface::TRANSFER2; use light_ctoken_sdk::compressed_token::transfer2::account_metas::{ get_transfer2_instruction_account_metas, Transfer2AccountsMetaConfig, }; @@ -852,14 +854,16 @@ async fn test_ata_decompress_with_mismatched_amount_fails() { let tree_index = packed_accounts.insert_or_get(merkle_tree); let queue_index = packed_accounts.insert_or_get(queue); - // Add mint and owner + // Add mint and wallet owner (for signing and TLV owner_index) let mint_index = packed_accounts.insert_or_get_read_only(compressed_account.token.mint); - let owner_index = packed_accounts.insert_or_get_config(context.owner.pubkey(), true, false); + let wallet_owner_index = + packed_accounts.insert_or_get_config(context.owner.pubkey(), true, false); - // Add CToken ATA recipient account + // Add CToken ATA recipient account - this is also the compressed token owner for ATAs let ctoken_ata_index = packed_accounts.insert_or_get_config(context.ata_pubkey, false, true); // Create input token data with FULL amount (what merkle proof verifies) + // For ATA compressed tokens, owner is the ATA pubkey (not wallet) let has_delegate = compressed_account.token.delegate.is_some(); let delegate_index = if has_delegate { packed_accounts @@ -869,7 +873,7 @@ async fn test_ata_decompress_with_mismatched_amount_fails() { }; let input_token_data = vec![MultiInputTokenDataWithContext { - owner: owner_index, + owner: ctoken_ata_index, // ATA pubkey is the compressed token owner amount: compressed_account.token.amount, // Full amount for merkle proof has_delegate, delegate: delegate_index, @@ -913,6 +917,7 @@ async fn test_ata_decompress_with_mismatched_amount_fails() { ]; // Build in_tlv for CompressedOnly extension + // owner_index in TLV is the wallet owner (who can sign), not the ATA let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( CompressedOnlyExtensionInstructionData { delegated_amount: 0, @@ -921,7 +926,7 @@ async fn test_ata_decompress_with_mismatched_amount_fails() { compression_index: 0, is_ata: true, bump: context.ata_bump, - owner_index, + owner_index: wallet_owner_index, }, )]]; diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index 99290c34c5..49142f7717 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -45,68 +45,65 @@ Every instruction description must include the sections: ## Instruction Index ### Account Management -1. **Create CToken Account** - [`docs/instructions/CREATE_TOKEN_ACCOUNT.md`](docs/instructions/CREATE_TOKEN_ACCOUNT.md) +1. **Create CToken Account** - [`docs/ctoken/CREATE.md`](docs/ctoken/CREATE.md) - Create regular token account (discriminator: 18, enum: `InstructionType::CreateTokenAccount`) - Create associated token account (discriminator: 100, enum: `InstructionType::CreateAssociatedCTokenAccount`) - Create associated token account idempotent (discriminator: 102, enum: `InstructionType::CreateAssociatedTokenAccountIdempotent`) - **Config validation:** Requires ACTIVE config only -2. **Close Token Account** - [`docs/instructions/CLOSE_TOKEN_ACCOUNT.md`](docs/instructions/CLOSE_TOKEN_ACCOUNT.md) (discriminator: 9, enum: `InstructionType::CloseTokenAccount`) +2. **Close Token Account** - [`docs/ctoken/CLOSE.md`](docs/ctoken/CLOSE.md) (discriminator: 9, enum: `InstructionType::CloseTokenAccount`) - Close decompressed token accounts - Returns rent exemption to rent recipient if compressible - Returns remaining lamports to destination account ### Rent Management -3. **Claim** - [`docs/instructions/CLAIM.md`](docs/instructions/CLAIM.md) +3. **Claim** - [`docs/compressible/CLAIM.md`](docs/compressible/CLAIM.md) - Claims rent from expired compressible accounts (discriminator: 104, enum: `InstructionType::Claim`) - **Config validation:** Not inactive (active or deprecated OK) -4. **Withdraw Funding Pool** - [`docs/instructions/WITHDRAW_FUNDING_POOL.md`](docs/instructions/WITHDRAW_FUNDING_POOL.md) +4. **Withdraw Funding Pool** - [`docs/compressible/WITHDRAW_FUNDING_POOL.md`](docs/compressible/WITHDRAW_FUNDING_POOL.md) - Withdraws funds from rent recipient pool (discriminator: 105, enum: `InstructionType::WithdrawFundingPool`) - **Config validation:** Not inactive (active or deprecated OK) ### Token Operations -5. **Transfer2** - [`docs/instructions/TRANSFER2.md`](docs/instructions/TRANSFER2.md) +5. **Transfer2** - [`docs/compressed_token/TRANSFER2.md`](docs/compressed_token/TRANSFER2.md) - Batch transfer instruction for compressed/decompressed operations (discriminator: 101, enum: `InstructionType::Transfer2`) - Supports Compress, Decompress, CompressAndClose operations - Multi-mint support with sum checks -6. **MintAction** - [`docs/instructions/MINT_ACTION.md`](docs/instructions/MINT_ACTION.md) +6. **MintAction** - [`docs/compressed_token/MINT_ACTION.md`](docs/compressed_token/MINT_ACTION.md) - Batch instruction for compressed mint management and mint operations (discriminator: 103, enum: `InstructionType::MintAction`) - Supports 10 action types: CreateCompressedMint, MintTo, UpdateMintAuthority, UpdateFreezeAuthority, MintToCToken, UpdateMetadataField, UpdateMetadataAuthority, RemoveMetadataKey, DecompressMint, CompressAndCloseCMint - Handles both compressed and decompressed token minting -7. **CTokenTransfer** - [`docs/instructions/CTOKEN_TRANSFER.md`](docs/instructions/CTOKEN_TRANSFER.md) +7. **CTokenTransfer** - [`docs/ctoken/TRANSFER.md`](docs/ctoken/TRANSFER.md) - Transfer between decompressed accounts (discriminator: 3, enum: `InstructionType::CTokenTransfer`) -8. **CTokenTransferChecked** - [`docs/instructions/CTOKEN_TRANSFER_CHECKED.md`](docs/instructions/CTOKEN_TRANSFER_CHECKED.md) +8. **CTokenTransferChecked** - [`docs/ctoken/TRANSFER_CHECKED.md`](docs/ctoken/TRANSFER_CHECKED.md) - Transfer with decimals validation (discriminator: 12, enum: `InstructionType::CTokenTransferChecked`) -9. **CTokenApprove** - [`docs/instructions/CTOKEN_APPROVE.md`](docs/instructions/CTOKEN_APPROVE.md) +9. **CTokenApprove** - [`docs/ctoken/APPROVE.md`](docs/ctoken/APPROVE.md) - Approve delegate on decompressed CToken account (discriminator: 4, enum: `InstructionType::CTokenApprove`) -10. **CTokenRevoke** - [`docs/instructions/CTOKEN_REVOKE.md`](docs/instructions/CTOKEN_REVOKE.md) +10. **CTokenRevoke** - [`docs/ctoken/REVOKE.md`](docs/ctoken/REVOKE.md) - Revoke delegate on decompressed CToken account (discriminator: 5, enum: `InstructionType::CTokenRevoke`) -11. **CTokenMintTo** - [`docs/instructions/CTOKEN_MINT_TO.md`](docs/instructions/CTOKEN_MINT_TO.md) +11. **CTokenMintTo** - [`docs/ctoken/MINT_TO.md`](docs/ctoken/MINT_TO.md) - Mint tokens to decompressed CToken account (discriminator: 7, enum: `InstructionType::CTokenMintTo`) -12. **CTokenBurn** - [`docs/instructions/CTOKEN_BURN.md`](docs/instructions/CTOKEN_BURN.md) +12. **CTokenBurn** - [`docs/ctoken/BURN.md`](docs/ctoken/BURN.md) - Burn tokens from decompressed CToken account (discriminator: 8, enum: `InstructionType::CTokenBurn`) -13. **CTokenFreezeAccount** - [`docs/instructions/CTOKEN_FREEZE_ACCOUNT.md`](docs/instructions/CTOKEN_FREEZE_ACCOUNT.md) +13. **CTokenFreezeAccount** - [`docs/ctoken/FREEZE_ACCOUNT.md`](docs/ctoken/FREEZE_ACCOUNT.md) - Freeze decompressed CToken account (discriminator: 10, enum: `InstructionType::CTokenFreezeAccount`) -14. **CTokenThawAccount** - [`docs/instructions/CTOKEN_THAW_ACCOUNT.md`](docs/instructions/CTOKEN_THAW_ACCOUNT.md) +14. **CTokenThawAccount** - [`docs/ctoken/THAW_ACCOUNT.md`](docs/ctoken/THAW_ACCOUNT.md) - Thaw frozen decompressed CToken account (discriminator: 11, enum: `InstructionType::CTokenThawAccount`) -15. **CTokenApproveChecked** - [`docs/instructions/CTOKEN_APPROVE_CHECKED.md`](docs/instructions/CTOKEN_APPROVE_CHECKED.md) - - Approve delegate with decimals validation (discriminator: 13, enum: `InstructionType::CTokenApproveChecked`) - -16. **CTokenMintToChecked** - [`docs/instructions/CTOKEN_MINT_TO_CHECKED.md`](docs/instructions/CTOKEN_MINT_TO_CHECKED.md) +15. **CTokenMintToChecked** - [`docs/ctoken/MINT_TO_CHECKED.md`](docs/ctoken/MINT_TO_CHECKED.md) - Mint tokens with decimals validation (discriminator: 14, enum: `InstructionType::CTokenMintToChecked`) -17. **CTokenBurnChecked** - [`docs/instructions/CTOKEN_BURN_CHECKED.md`](docs/instructions/CTOKEN_BURN_CHECKED.md) +16. **CTokenBurnChecked** - [`docs/ctoken/BURN_CHECKED.md`](docs/ctoken/BURN_CHECKED.md) - Burn tokens with decimals validation (discriminator: 15, enum: `InstructionType::CTokenBurnChecked`) ## Config State Requirements Summary @@ -124,7 +121,7 @@ src/ │ ├── claim.rs # Claim instruction (104) │ └── withdraw_funding_pool.rs # WithdrawFundingPool instruction (105) ├── ctoken/ # Operations on CToken Solana accounts (decompressed) -│ ├── approve_revoke.rs # CTokenApprove (4), CTokenRevoke (5), CTokenApproveChecked (13) +│ ├── approve_revoke.rs # CTokenApprove (4), CTokenRevoke (5) │ ├── burn.rs # CTokenBurn (8), CTokenBurnChecked (15) │ ├── close/ # CloseTokenAccount instruction (9) │ ├── create.rs # CreateTokenAccount instruction (18) @@ -162,7 +159,7 @@ Operations on CToken Solana accounts (decompressed compressed tokens). - `default.rs` - CTokenTransfer (discriminator: 3) - `checked.rs` - CTokenTransferChecked (discriminator: 12) - `shared.rs` - Common transfer utilities -- **`approve_revoke.rs`** - CTokenApprove (4), CTokenRevoke (5), CTokenApproveChecked (13) +- **`approve_revoke.rs`** - CTokenApprove (4), CTokenRevoke (5) - **`mint_to.rs`** - CTokenMintTo (7), CTokenMintToChecked (14) - **`burn.rs`** - CTokenBurn (8), CTokenBurnChecked (15) - **`freeze_thaw.rs`** - CTokenFreezeAccount (10), CTokenThawAccount (11) diff --git a/programs/compressed-token/program/docs/ACCOUNTS.md b/programs/compressed-token/program/docs/ACCOUNTS.md index 5f0a38b637..0bf03ba659 100644 --- a/programs/compressed-token/program/docs/ACCOUNTS.md +++ b/programs/compressed-token/program/docs/ACCOUNTS.md @@ -19,12 +19,24 @@ path: `program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs` crate: `light-ctoken-interface` - **associated instructions** - 1. `CreateTokenAccount` `18` - 2. `CloseTokenAccount` `9` - 3. `CTokenTransfer` `3` - 4. `Transfer2` `101` - `Decompress`, `DecompressAndClose` - 5. `MintAction` `103` - `MintToCToken` - 6. `Claim` `104` + 1. `CTokenTransfer` `3` + 2. `CTokenApprove` `4` + 3. `CTokenRevoke` `5` + 4. `CTokenMintTo` `7` + 5. `CTokenBurn` `8` + 6. `CloseTokenAccount` `9` + 7. `CTokenFreezeAccount` `10` + 8. `CTokenThawAccount` `11` + 9. `CTokenTransferChecked` `12` + 10. `CTokenMintToChecked` `14` + 11. `CTokenBurnChecked` `15` + 12. `CreateTokenAccount` `18` + 13. `CreateAssociatedCTokenAccount` `100` + 14. `Transfer2` `101` - `Decompress`, `DecompressAndClose` + 15. `CreateAssociatedTokenAccountIdempotent` `102` + 16. `MintAction` `103` - `MintToCToken` + 17. `Claim` `104` + 18. `WithdrawFundingPool` `105` - **serialization example** borsh and zero copy deserialization deserialize the compressible extension, spl serialization only deserialize the base token data. zero copy: (always use in programs) @@ -82,13 +94,39 @@ ### Compressed Mint ## Extensions -The compressed token program supports 2 extensions. +The compressed token program supports multiple extensions defined in `program-libs/ctoken-interface/src/state/extensions/`. -### TokenMetadata -- Mint extension, compatible with TokenMetada extension of Token2022. +### Mint Extensions + +#### TokenMetadata +- Mint extension, compatible with TokenMetadata extension of Token2022. - Only available in compressed mints. +- Path: `program-libs/ctoken-interface/src/state/extensions/token_metadata.rs` + +### Token Account Extensions -### Compressible +#### Compressible - Token account extension, Token2022 does not have an equivalent extension. - Only available in ctoken solana accounts (decompressed ctokens), not in compressed token accounts. -- +- Stores compression info (rent sponsor, config, creation slot, etc.) for rent management. +- Path: `program-libs/ctoken-interface/src/state/extensions/compressible.rs` + +#### CompressedOnly +- Marker extension indicating the account can only exist in compressed form. +- Path: `program-libs/ctoken-interface/src/state/extensions/compressed_only.rs` + +#### Pausable +- Token account extension compatible with Token2022 PausableAccount extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/pausable.rs` + +#### PermanentDelegate +- Token account extension compatible with Token2022 PermanentDelegate extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/permanent_delegate.rs` + +#### TransferFee +- Token account extension compatible with Token2022 TransferFee extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/transfer_fee.rs` + +#### TransferHook +- Token account extension compatible with Token2022 TransferHook extension. +- Path: `program-libs/ctoken-interface/src/state/extensions/transfer_hook.rs` diff --git a/programs/compressed-token/program/docs/CLAUDE.md b/programs/compressed-token/program/docs/CLAUDE.md index 3f328b6b7c..f08fa26158 100644 --- a/programs/compressed-token/program/docs/CLAUDE.md +++ b/programs/compressed-token/program/docs/CLAUDE.md @@ -27,7 +27,6 @@ This documentation is organized to provide clear navigation through the compress - `TRANSFER.md` - Transfer between decompressed accounts - `TRANSFER_CHECKED.md` - Transfer with decimals validation - `APPROVE.md` - Approve delegate - - `APPROVE_CHECKED.md` - Approve with decimals validation - `REVOKE.md` - Revoke delegate - `MINT_TO.md` - Mint tokens to CToken account - `MINT_TO_CHECKED.md` - Mint with decimals validation diff --git a/programs/compressed-token/program/docs/EXTENSIONS.md b/programs/compressed-token/program/docs/EXTENSIONS.md index d2c39d30ec..7eaff32385 100644 --- a/programs/compressed-token/program/docs/EXTENSIONS.md +++ b/programs/compressed-token/program/docs/EXTENSIONS.md @@ -6,7 +6,7 @@ This document describes how Token-2022 extensions are validated across compresse The compressed token program supports 16 Token-2022 extension types. **5 restricted extensions** require instruction-level validation checks. Pure mint extensions (metadata, group, etc.) are allowed without explicit instruction support. -**Allowed extensions** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:24-44`): +**Allowed extensions** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:17-44`): 1. MetadataPointer 2. TokenMetadata @@ -84,7 +84,7 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric **Validation paths:** - `programs/compressed-token/anchor/src/instructions/create_token_pool.rs:142-153` - `assert_mint_extensions()` checks TransferFeeConfig -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:86-99` - `parse_mint_extensions()` checks TransferFeeConfig +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:86-99` - `parse_mint_extensions()` checks TransferFeeConfig (lines 86-99 in file) **Unchecked instructions:** 1. CTokenApprove @@ -110,7 +110,7 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric **Validation paths:** - `programs/compressed-token/anchor/src/instructions/create_token_pool.rs:155-162` - `assert_mint_extensions()` checks TransferHook -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:101-107` - `parse_mint_extensions()` checks TransferHook +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:101-107` - `parse_mint_extensions()` checks TransferHook (lines 101-107 in file) **Unchecked instructions:** 1. CTokenApprove @@ -134,9 +134,9 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric | CTokenTransfer | `parse_mint_extensions()` → `verify_owner_or_delegate_signer()` | Extract delegate pubkey, then validate authority is owner OR delegate. If authority matches permanent delegate, that account must be a signer. | `OwnerMismatch` (6075) or `MissingRequiredSignature` | **Validation paths:** -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:77-84` - Extracts delegate pubkey in `parse_mint_extensions()` +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:76-84` - Extracts delegate pubkey in `parse_mint_extensions()` - `programs/compressed-token/program/src/shared/owner_validation.rs:30-78` - `verify_owner_or_delegate_signer()` validates delegate/permanent delegate signer -- `programs/compressed-token/program/src/ctoken/transfer/shared.rs:164-179` - `validate_permanent_delegate()` +- `programs/compressed-token/program/src/ctoken/transfer/shared.rs:196-214` - `validate_permanent_delegate()` **Unchecked instructions:** 1. CTokenApprove @@ -160,8 +160,8 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric | CTokenTransfer | `check_mint_extensions()` | `pausable_config.paused == false` | `MintPaused` (6127) | **Validation path:** -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:71-74` - `parse_mint_extensions()` checks PausableConfig.paused -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:147-149` - `check_mint_extensions()` throws MintPaused error +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:70-74` - `parse_mint_extensions()` checks PausableConfig.paused +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:147-150` - `check_mint_extensions()` throws MintPaused error **Unchecked instructions:** 1. CTokenApprove - allowed when paused (only affects delegation, not token movement) @@ -186,8 +186,8 @@ The compressed token program supports 16 Token-2022 extension types. **5 restric | Transfer2 (Decompress) | - | Restores frozen state from CompressedOnly extension | - | **Validation paths:** -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:211-220` - Detects `default_state_frozen` -- `programs/compressed-token/program/src/shared/initialize_ctoken_account.rs:96-100` - Applies frozen state +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:213-220` - Detects `default_state_frozen` in `has_mint_extensions()` +- `programs/compressed-token/program/src/shared/initialize_ctoken_account.rs:190-198` - Applies frozen state in `initialize_ctoken_account()` **Account Initialization:** ```rust @@ -228,6 +228,8 @@ pub struct CompressedOnlyExtension { pub delegated_amount: u64, /// Withheld transfer fee amount from the source CToken account. pub withheld_transfer_fee: u64, + /// Whether the source was an ATA (1) or regular token account (0). + pub is_ata: u8, } ``` @@ -242,6 +244,12 @@ pub struct CompressedOnlyExtensionInstructionData { pub is_frozen: bool, /// Index of the compression operation that consumes this input. pub compression_index: u8, + /// Whether the source CToken account was an ATA. + pub is_ata: bool, + /// ATA derivation bump (only used when is_ata=true). + pub bump: u8, + /// Index into packed_accounts for the actual owner (only used when is_ata=true). + pub owner_index: u8, } ``` @@ -256,15 +264,21 @@ pub struct CompressedOnlyExtensionInstructionData { - Output compressed token must include CompressedOnly extension in TLV data - Extension values must match source CToken state -**Validation (lines 168-277 in `validate_compressed_token_account`):** -1. If source has `compression_only=true`, CompressedOnly extension is required (line 173-175) -2. `delegated_amount` must match source CToken's `delegated_amount` (lines 181-188) -3. Delegate pubkey must match if delegated_amount > 0 (lines 189-210) -4. `withheld_transfer_fee` must match source's TransferFeeAccount withheld amount (lines 211-237) -5. `is_frozen` must match source CToken's frozen state (`state == 2`) (lines 239-251) -6. If source is frozen but extension missing → `CompressAndCloseMissingCompressedOnlyExtension` (lines 253-259) - -**Source CToken Reset (lines 71-74 in `process_compress_and_close`):** +**Validation (in `validate_compressed_token_account` and `validate_compressed_only_ext`):** +1. Owner must match (lines 103-115): output owner must match ctoken owner (or token account pubkey for ATA/compress_to_pubkey) +2. Amount must match (lines 117-121): compression_amount == output_amount == ctoken.amount +3. Mint must match (lines 123-129): output mint matches ctoken mint +4. Version must be ShaFlat (lines 131-134) +5. Extension required for compression_only or ATA accounts (lines 136-145) +6. Without extension: must not be frozen, must not have delegate (lines 147-156) +7. With extension (`validate_compressed_only_ext` function, lines 170-222): + - 7a. `delegated_amount` must match (lines 177-181) + - 7b. Delegate pubkey must match if present (lines 183-194) + - 7c. `withheld_transfer_fee` must match (lines 196-209) + - 7d. `is_frozen` must match (lines 211-214) + - 7e. `is_ata` must match (lines 216-219) + +**Source CToken Reset (lines 68-71 in `process_compress_and_close`):** ```rust ctoken.base.amount.set(0); // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) @@ -278,20 +292,17 @@ ctoken.base.set_initialized(); **Trigger:** Decompressing a compressed token that has CompressedOnly extension. -**State Restoration (`apply_decompress_extension_state` function, lines 56-128):** -1. Extract CompressedOnly data from input TLV (lines 65-77) -2. Validate destination is fresh with matching owner via `validate_decompression_destination` (lines 15-50) -3. Restore delegate pubkey from instruction input account (lines 85-96) -4. Restore `delegated_amount` to destination CToken (lines 99-101) -5. Restore `withheld_transfer_fee` to TransferFeeAccount extension (lines 104-120) -6. Restore frozen state via `ctoken.base.set_frozen()` (lines 122-125) - -**Validation (`validate_decompression_destination`, lines 15-50):** -- Destination owner must match input owner -- Destination amount must be 0 -- Destination must not have delegate -- Destination delegated_amount must be 0 -- Destination must not have close_authority +**State Restoration (`validate_and_apply_compressed_only` function, lines 15-70):** +1. Return early if no decompress inputs or no CompressedOnly extension (lines 23-29) +2. Validate amount matches for ATA or compress_to_pubkey decompress (lines 31-46) +3. Validate destination ownership via `validate_destination` (lines 48-56) +4. Restore delegate pubkey and delegated_amount via `apply_delegate` (lines 58-59) +5. Restore `withheld_transfer_fee` via `apply_withheld_fee` (lines 61-62) +6. Restore frozen state via `ctoken.base.set_frozen()` (lines 64-67) + +**Validation (`validate_destination`, lines 77-106):** +- For non-ATA: CToken owner must match input owner +- For ATA: destination address must match input owner (ATA pubkey), and CToken owner must match wallet owner ### State Preservation Matrix @@ -300,6 +311,7 @@ ctoken.base.set_initialized(); | delegated_amount | ✅ | ✅ | Stored in extension | | withheld_transfer_fee | ✅ | ✅ | Restored to TransferFeeAccount | | is_frozen | ✅ | ✅ | Restored via `set_frozen()` | +| is_ata | ✅ | ✅ | Used to validate ATA derivation on decompress | | delegate pubkey | Validated | From input | Passed as instruction account | | amount | ❌ (set to 0) | From compression | New amount from compressed token | | close_authority | ❌ | ❌ | Not preserved | @@ -312,6 +324,9 @@ ctoken.base.set_initialized(); | `CompressAndCloseDelegatedAmountMismatch` | 6135 | delegated_amount doesn't match source | | `CompressAndCloseWithheldFeeMismatch` | 6137 | withheld_transfer_fee doesn't match source | | `CompressAndCloseFrozenMismatch` | 6138 | is_frozen doesn't match source frozen state | +| `CompressAndCloseIsAtaMismatch` | N/A | is_ata doesn't match source ATA flag | +| `CompressAndCloseInvalidDelegate` | N/A | delegate pubkey doesn't match source | +| `CompressAndCloseDelegateNotAllowed` | N/A | delegate present but CompressedOnly extension missing | --- @@ -333,12 +348,12 @@ ctoken.base.set_initialized(); --- ### `has_mint_extensions()` -**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:175-230` +**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:174-230` **Used by:** CreateTokenAccount (detection only) **Behavior:** -1. Return default flags if not Token-2022 mint (lines 177-179) +1. Return default flags if not Token-2022 mint (lines 176-179) 2. Deserialize mint with `PodStateWithExtensions::unpack()` (lines 181-184) 3. Get all extension types in a single call (line 187) 4. Validate all extensions are in `ALLOWED_EXTENSION_TYPES` → `MintWithInvalidExtension` (lines 196-200) @@ -346,7 +361,7 @@ ctoken.base.set_initialized(); 6. Check if DefaultAccountState is set to Frozen (lines 213-220) 7. Return `MintExtensionFlags` with boolean flags -**Returns** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:59-74`): +**Returns** (defined in `program-libs/ctoken-interface/src/token_2022_extensions.rs:59-75`): ```rust MintExtensionFlags { has_pausable: bool, @@ -368,15 +383,15 @@ MintExtensionFlags { **Used by:** Internal helper for `check_mint_extensions()` and `build_mint_extension_cache()` **Behavior:** -1. Return default if not Token-2022 mint (lines 57-59) +1. Return default if not Token-2022 mint (lines 56-59) 2. Deserialize mint with `PodStateWithExtensions::unpack()` (lines 61-64) 3. Compute `has_restricted_extensions` from extension types (lines 66-68) -4. Check if Pausable extension exists and paused state (lines 71-74) -5. Extract PermanentDelegate pubkey if exists (lines 77-84) -6. Check TransferFeeConfig for non-zero fees (lines 87-99) -7. Check TransferHook for non-nil program_id (lines 102-107) +4. Check if Pausable extension exists and paused state (lines 70-74) +5. Extract PermanentDelegate pubkey if exists (lines 76-84) +6. Check TransferFeeConfig for non-zero fees (lines 86-99) +7. Check TransferHook for non-nil program_id (lines 101-107) -**Returns** (defined in `check_mint_extensions.rs:21-40`): +**Returns** (defined in `check_mint_extensions.rs:22-40`): ```rust MintExtensionChecks { permanent_delegate: Option, // For signer validation @@ -391,7 +406,7 @@ MintExtensionChecks { --- ### `check_mint_extensions()` -**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:134-159` +**Path:** `programs/compressed-token/program/src/extensions/check_mint_extensions.rs:133-159` **Used by:** Transfer2, CTokenTransfer (runtime validation) @@ -401,31 +416,31 @@ MintExtensionChecks { **Behavior:** Wrapper around `parse_mint_extensions()` that throws errors for invalid states: 1. Call `parse_mint_extensions()` (line 138) -2. If `deny_restricted_extensions && has_restricted_extensions` → `MintHasRestrictedExtensions` (6142) (lines 141-145) -3. If `is_paused == true` → `MintPaused` (6127) (lines 148-150) +2. If `deny_restricted_extensions && has_restricted_extensions` → `MintHasRestrictedExtensions` (6142) (lines 140-145) +3. If `is_paused == true` → `MintPaused` (6127) (lines 147-150) 4. If `has_non_zero_transfer_fee` → `NonZeroTransferFeeNotSupported` (6129) (lines 151-153) 5. If `has_non_nil_transfer_hook` → `TransferHookNotSupported` (6130) (lines 154-156) --- ### `build_mint_extension_cache()` -**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs:77-145` +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/check_extensions.rs:78-158` **Used by:** Transfer2 (batch validation) **Behavior:** -1. For each unique mint in inputs (lines 85-97): +1. For each unique mint in inputs (lines 89-101): - If no outputs: call `parse_mint_extensions()` (bypass state checks for pure decompress) - Otherwise: call `check_mint_extensions()` with `deny_restricted_extensions` - Cache result in `ArrayMap` -2. For each unique mint in compressions (lines 100-142): +2. For each unique mint in compressions (lines 103-142): - CompressAndClose and full Decompress: use `parse_mint_extensions()` (bypass state checks) - Otherwise: use `check_mint_extensions()` with `deny_restricted_extensions` -3. Special handling for CompressAndClose mode (lines 116-137): +3. Special handling for CompressAndClose mode (lines 121-140): - Mints with restricted extensions require CompressedOnly output extension - If missing → `CompressAndCloseMissingCompressedOnlyExtension` (6133) -**Returns:** `MintExtensionCache` (type alias defined at line 46) - Cached checks keyed by mint account index +**Returns:** `MintExtensionCache` (type alias defined at line 49) - Cached checks keyed by mint account index --- @@ -448,14 +463,14 @@ MintExtensionChecks { **Enforcement:** `build_mint_extension_cache()` is called with `deny_restricted_extensions = !out_token_data.is_empty()` **Flow:** -1. `build_mint_extension_cache()` computes `deny_restricted_extensions = !inputs.out_token_data.is_empty()` (line 82) -2. For input mints: calls `check_mint_extensions(mint, deny_restricted_extensions)` (line 93) +1. `build_mint_extension_cache()` computes `deny_restricted_extensions = !inputs.out_token_data.is_empty()` (line 86) +2. For input mints: calls `check_mint_extensions(mint, deny_restricted_extensions)` (line 97) 3. If `deny_restricted_extensions=true` and mint has restricted extensions → `MintHasRestrictedExtensions` (6142) **Exception - CompressAndClose and Decompress modes:** -- CompressAndClose: calls `parse_mint_extensions()` to bypass state checks (line 111) -- Full Decompress (no outputs): calls `parse_mint_extensions()` to bypass state checks (lines 89-91) -- CompressAndClose still requires CompressedOnly output extension for restricted mints (lines 116-137) +- CompressAndClose: calls `parse_mint_extensions()` to bypass state checks (line 112) +- Full Decompress (no outputs): calls `parse_mint_extensions()` to bypass state checks (lines 93-95) +- CompressAndClose still requires CompressedOnly output extension for restricted mints (lines 125-140) - If missing → `CompressAndCloseMissingCompressedOnlyExtension` (6133) **Path:** `programs/compressed-token/program/src/compressed_token/transfer2/processor.rs:61` calls `build_mint_extension_cache()` diff --git a/programs/compressed-token/program/docs/INSTRUCTIONS.md b/programs/compressed-token/program/docs/INSTRUCTIONS.md index 5e769a590b..e6935cb0a4 100644 --- a/programs/compressed-token/program/docs/INSTRUCTIONS.md +++ b/programs/compressed-token/program/docs/INSTRUCTIONS.md @@ -1,37 +1,35 @@ -# Documentation Structure +# Instructions Reference ## Overview -This documentation is organized to provide clear navigation through the compressed token program's functionality. +This file contains the discriminator reference table and instruction index for the compressed token program. -## Structure -- **`CLAUDE.md`** (this file) - Documentation structure guide +## Related Documentation +- **`CLAUDE.md`** - Documentation structure guide - **`../CLAUDE.md`** (parent) - Main entry point with summary and instruction index - **`ACCOUNTS.md`** - Complete account layouts and data structures -- **`instructions/`** - Detailed instruction documentation - - **`compressed_token/`** - Compressed token operations (Merkle tree accounts) - - `TRANSFER2.md` - Batch transfer with compress/decompress operations - - `MINT_ACTION.md` - Mint operations and compressed mint management - - `FREEZE.md` - Freeze compressed token accounts (Anchor) - - `THAW.md` - Thaw frozen compressed token accounts (Anchor) - - **`compressible/`** - Rent management for compressible accounts - - `CLAIM.md` - Claim rent from expired compressible accounts - - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool - - **`ctoken/`** - CToken (decompressed) account operations - - `CREATE.md` - Create token account & associated token account - - `CLOSE.md` - Close decompressed token accounts - - `TRANSFER.md` - Transfer between decompressed accounts - - `TRANSFER_CHECKED.md` - Transfer with decimals validation - - `APPROVE.md` - Approve delegate - - `APPROVE_CHECKED.md` - Approve with decimals validation - - `REVOKE.md` - Revoke delegate - - `MINT_TO.md` - Mint tokens to CToken account - - `MINT_TO_CHECKED.md` - Mint with decimals validation - - `BURN.md` - Burn tokens from CToken account - - `BURN_CHECKED.md` - Burn with decimals validation - - `FREEZE_ACCOUNT.md` - Freeze CToken account - - `THAW_ACCOUNT.md` - Thaw frozen CToken account +- **`compressed_token/`** - Compressed token operations (Merkle tree accounts) + - `TRANSFER2.md` - Batch transfer with compress/decompress operations + - `MINT_ACTION.md` - Mint operations and compressed mint management + - `FREEZE.md` - Freeze compressed token accounts (Anchor) + - `THAW.md` - Thaw frozen compressed token accounts (Anchor) - `CREATE_TOKEN_POOL.md` - Create initial token pool for SPL/T22 mint compression - `ADD_TOKEN_POOL.md` - Add additional token pools (up to 5 per mint) +- **`compressible/`** - Rent management for compressible accounts + - `CLAIM.md` - Claim rent from expired compressible accounts + - `WITHDRAW_FUNDING_POOL.md` - Withdraw funds from rent recipient pool +- **`ctoken/`** - CToken (decompressed) account operations + - `CREATE.md` - Create token account & associated token account + - `CLOSE.md` - Close decompressed token accounts + - `TRANSFER.md` - Transfer between decompressed accounts + - `TRANSFER_CHECKED.md` - Transfer with decimals validation + - `APPROVE.md` - Approve delegate + - `REVOKE.md` - Revoke delegate + - `MINT_TO.md` - Mint tokens to CToken account + - `MINT_TO_CHECKED.md` - Mint with decimals validation + - `BURN.md` - Burn tokens from CToken account + - `BURN_CHECKED.md` - Burn with decimals validation + - `FREEZE_ACCOUNT.md` - Freeze CToken account + - `THAW_ACCOUNT.md` - Thaw frozen CToken account ## Discriminator Reference @@ -46,7 +44,6 @@ This documentation is organized to provide clear navigation through the compress | CTokenFreezeAccount | 10 | `InstructionType::CTokenFreezeAccount` | FreezeAccount | | CTokenThawAccount | 11 | `InstructionType::CTokenThawAccount` | ThawAccount | | CTokenTransferChecked | 12 | `InstructionType::CTokenTransferChecked` | TransferChecked | -| CTokenApproveChecked | 13 | `InstructionType::CTokenApproveChecked` | ApproveChecked | | CTokenMintToChecked | 14 | `InstructionType::CTokenMintToChecked` | MintToChecked | | CTokenBurnChecked | 15 | `InstructionType::CTokenBurnChecked` | BurnChecked | | CreateTokenAccount | 18 | `InstructionType::CreateTokenAccount` | InitializeAccount3 | @@ -65,8 +62,8 @@ This documentation is organized to provide clear navigation through the compress - CToken-specific instructions (100+) have no SPL Token equivalent ## Navigation Tips -- Start with `../../CLAUDE.md` for the instruction index and overview -- Use `../ACCOUNTS.md` for account structure reference +- Start with `../CLAUDE.md` for the instruction index and overview +- Use `ACCOUNTS.md` for account structure reference - Refer to specific instruction docs for implementation details @@ -95,7 +92,7 @@ every instruction description must include the sections: 9. **MintTo** - Mint tokens to decompressed CToken account 10. **Burn** - Burn tokens from decompressed CToken account 11. **Freeze/Thaw** - Freeze and thaw decompressed CToken accounts -12. **Checked Operations** - TransferChecked, ApproveChecked, MintToChecked, BurnChecked +12. **Checked Operations** - TransferChecked, MintToChecked, BurnChecked ## Compressible Operations (`compressible/`) 13. **Claim** - Rent reclamation from expired compressible accounts diff --git a/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md b/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md index c19de8a46a..206541a380 100644 --- a/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md +++ b/programs/compressed-token/program/docs/T22_VS_CTOKEN_COMPARISON.md @@ -34,7 +34,7 @@ This document compares the behavior of 5 restricted Token-2022 extensions betwee |-------------------|--------------------------------------------------|--------------------------------------------------| | Fee handling | Deducted from transfer, withheld in destination | Must be 0, otherwise `NonZeroTransferFeeNotSupported` | | CloseAccount | Blocked if `withheld_amount > 0` | No withheld check (fees always 0) | -| Account extension | TransferFeeAmount with `withheld_amount` field | TransferFeeAccount marker (no withheld tracking) | +| Account extension | TransferFeeAmount with `withheld_amount` field | TransferFeeAccountExtension with `withheld_amount` field | ### T22 Features Not Implemented @@ -115,7 +115,7 @@ CToken adds an account marker to identify accounts belonging to mints with perma |-------------------|-----------------------------------------------|----------------------------------------------| | Hook execution | CPI to program_id after balance update | No CPI (program_id must be nil) | | Reentrancy guard | `transferring` flag in TransferHookAccount | No guard needed (no CPI) | -| Account extension | TransferHookAccount with `transferring` field | TransferHookAccount marker (no transferring) | +| Account extension | TransferHookAccount with `transferring` field | TransferHookAccountExtension with `transferring` field (always false) | ### T22 Features Not Implemented @@ -193,11 +193,14 @@ Enables: ### CompressAndClose/Decompress Bypass (CToken-specific) ```rust -// Path: src/transfer2/check_extensions.rs:106-114 -let is_full_decompress = - compression.mode.is_decompress() && inputs.out_token_data.is_empty(); -let checks = if compression.mode.is_compress_and_close() || is_full_decompress { - // CompressAndClose and Decompress bypass extension state checks +// Path: src/compressed_token/transfer2/check_extensions.rs (build_mint_extension_cache function) +let no_compressed_outputs = inputs.out_token_data.is_empty(); + +// For compressions, bypass state checks when: +// - CompressAndClose mode, or +// - No compressed outputs (full decompress / CToken-to-SPL) +let checks = if compression.mode.is_compress_and_close() || no_compressed_outputs { + // Bypass extension state checks (paused, non-zero fees, non-nil transfer hook) parse_mint_extensions(mint_account)? // Extract data only } else { check_mint_extensions(mint_account, deny_restricted_extensions)? // Validate state @@ -215,15 +218,18 @@ This allows: ### Account Extension Markers -| Extension | T22 Adds Marker | CToken Adds Marker | -|---------------------|---------------------|----------------------------------| -| TransferFeeConfig | TransferFeeAmount | TransferFeeAccount | -| DefaultAccountState | None | None | -| PermanentDelegate | None | PermanentDelegateAccountExtension | -| TransferHook | TransferHookAccount | TransferHookAccount | -| Pausable | PausableAccount | PausableAccount | - -**Key difference:** T22's TransferFeeAmount and TransferHookAccount have data fields. CToken uses zero-sized markers. +| Extension | T22 Adds Marker | CToken Adds Marker | +|---------------------|---------------------|------------------------------------| +| TransferFeeConfig | TransferFeeAmount | TransferFeeAccountExtension | +| DefaultAccountState | None | None | +| PermanentDelegate | None | PermanentDelegateAccountExtension | +| TransferHook | TransferHookAccount | TransferHookAccountExtension | +| Pausable | PausableAccount | PausableAccountExtension | + +**Key differences:** +- T22's TransferFeeAmount has `withheld_amount` field. CToken's TransferFeeAccountExtension also has `withheld_amount` for state preservation during compress/decompress cycles. +- T22's TransferHookAccount has `transferring` flag for reentrancy guard. CToken's TransferHookAccountExtension has the same field but it's always false (no CPI invocation). +- PermanentDelegateAccountExtension and PausableAccountExtension are zero-sized markers in CToken. ### Validation Function Comparison diff --git a/programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md b/programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md index 8ae26a1ad1..94b4f3cfa4 100644 --- a/programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md +++ b/programs/compressed-token/program/docs/compressed_token/CREATE_TOKEN_POOL.md @@ -39,7 +39,7 @@ Token pool pda is renamed to spl interface pda in the light-token-sdk. - Token program interface (SPL Token or Token-2022) 6. cpi_authority_pda - CPI authority PDA - - PDA derivation: seeds=[b"light_cpi_authority"], program=light_compressed_token + - PDA derivation: seeds=[b"cpi_authority"], program=light_compressed_token - Becomes the owner/authority of the token pool account **Instruction Logic and Checks:** diff --git a/programs/compressed-token/program/docs/compressed_token/FREEZE.md b/programs/compressed-token/program/docs/compressed_token/FREEZE.md index 08fc60b1ed..a3637c6f4a 100644 --- a/programs/compressed-token/program/docs/compressed_token/FREEZE.md +++ b/programs/compressed-token/program/docs/compressed_token/FREEZE.md @@ -69,7 +69,7 @@ Supports multiple hashing versions via an optional trailing version byte: - Return `InvalidFreezeAuthority` if authority doesn't match 4. **Build input compressed accounts:** - - Call `get_input_compressed_accounts_with_merkle_context_and_check_signer::` (FROZEN_INPUTS=false) + - Call `get_input_compressed_accounts_with_merkle_context_and_check_signer::` (IS_FROZEN=false) - Reconstruct token data from inputs using owner from instruction data - Set input state to Initialized (expected input state) diff --git a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index f123acacd4..07618b677b 100644 --- a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -30,8 +30,8 @@ This instruction supports 10 total actions - one creation action (controlled by 10. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). Key concepts integrated: -- **Compressed mint (cmint)**: Mint state stored in compressed account with deterministic address derived from associated SPL mint pubkey -- **SPL mint synchronization**: When SPL mint exists, supply is tracked in both compressed mint and SPL mint through token pool PDAs +- **Compressed mint (cmint)**: Mint state stored in compressed account with deterministic address derived from a mint signer PDA +- **Decompressed mint (CMint)**: When a compressed mint is decompressed, a CMint Solana account becomes the source of truth - **Authority validation**: All actions require appropriate authority (mint/freeze/metadata) to be transaction signer - **Batch processing**: Multiple actions execute sequentially with state updates persisted between actions @@ -41,10 +41,7 @@ Key concepts integrated: **Core fields:** - `leaf_index`: u32 - Merkle tree leaf index of existing compressed mint (only used if create_mint is None) - `prove_by_index`: bool - Use proof-by-index for existing mint validation (only used if create_mint is None) - - `root_index`: u16 - Root index for address proof (create) or validity proof (update) - - `compressed_address`: [u8; 32] - Deterministic address derived from SPL mint pubkey - - `token_pool_bump`: u8 - Token pool PDA bump (required for SPL mint operations) - - `token_pool_index`: u8 - Token pool PDA index (required for SPL mint operations) + - `root_index`: u16 - Root index for address proof (create) or validity proof (update). Not used if proof by index. - `max_top_up`: u16 - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) - `create_mint`: Option - Configuration for creating new compressed mint (None for existing mint operations) - `actions`: Vec - Ordered list of actions to execute @@ -64,32 +61,36 @@ Key concepts integrated: - `CompressAndCloseCMint(CompressAndCloseCMintAction)` - Compress and close CMint Solana account (compress_and_close_cmint.rs) **Accounts:** + +The account ordering differs based on whether writing to CPI context or executing. + +**Always present:** 1. light_system_program - non-mutable - - Light Protocol system program for cpi to create or update the compressed mint account. + - Light Protocol system program for CPI to create or update the compressed mint account. -Optional accounts (based on configuration): -2. mint_signer - - (signer) - required if create_mint is Some or DecompressMint action present - - PDA seed for SPL mint creation (seeds from compressed mint randomness) +2. mint_signer (optional) + - (signer if create_mint is Some, non-signer for DecompressMint) + - Required if create_mint is Some or DecompressMint action is present + - PDA seed derivation from compressed mint randomness 3. authority - (signer) - Must match current mint/freeze/metadata authority for respective actions -For execution (when not writing to CPI context): -4. mint - - (mutable) - optional, required for SPL mint supply synchronization - - SPL Token 2022 mint account for supply synchronization +**For execution (when not writing to CPI context):** + +4. compressible_config (optional) + - Required when DecompressMint or CompressAndCloseCMint action is present + - CompressibleConfig account - parsed and validated for active state -5. token_pool_pda - - (mutable) - optional, required for SPL mint supply synchronization - - Token pool PDA that holds SPL tokens backing compressed supply - - Derivation: [mint, token_pool_index] with token_pool_bump +5. cmint (optional) + - (mutable) - CMint Solana account (decompressed compressed mint) + - Required when cmint_decompressed=true OR DecompressMint OR CompressAndCloseCMint action present -6. token_program - - non-mutable - optional, required for SPL mint supply synchronization - - Must be SPL Token 2022 program (validated in accounts.rs:126) +6. rent_sponsor (optional) + - (mutable) - Required when DecompressMint or CompressAndCloseCMint action is present + - Rent sponsor PDA that pays for CMint account creation 7-12. Light system accounts (standard set): - fee_payer (signer, mutable) @@ -98,6 +99,9 @@ For execution (when not writing to CPI context): - account_compression_authority - account_compression_program - system_program + - sol_pool_pda (optional) + - sol_decompression_recipient (optional) + - cpi_context (optional) 13. out_output_queue - (mutable) @@ -116,10 +120,13 @@ For execution (when not writing to CPI context): - (mutable) - optional, required for MintToCompressed actions - Output queue for newly minted compressed token accounts -For CPI context write (when write_to_cpi_context=true): -4-6. CPI context accounts only +**For CPI context write (when write_to_cpi_context=true):** +4-6. CPI context accounts: + - fee_payer (signer, mutable) + - cpi_authority_pda + - cpi_context -Packed accounts (remaining accounts): +**Packed accounts (remaining accounts):** - Merkle tree and queue accounts for compressed storage - Recipient ctoken accounts for MintToCToken action @@ -132,15 +139,17 @@ Packed accounts (remaining accounts): 2. **Validate and parse accounts:** - Check authority is signer - - If SPL mint initialized: validate token pool PDA derivation - - Validate mint account matches expected cmint pubkey + - Validate CMint account matches expected mint pubkey (when cmint_pubkey provided) - For create_mint: validate address_merkle_tree is CMINT_ADDRESS_TREE + - Parse compressible config when DecompressMint or CompressAndCloseCMint action present - Extract packed accounts for dynamic operations 3. **Process mint creation or input:** - If create_mint is Some: - - Derive SPL mint PDA from compressed address - - Set create address in CPI instruction + - Derive mint PDA from mint_signer key: `find_program_address([COMPRESSED_MINT_SEED, mint_signer], program_id)` + - Validate mint.metadata.mint matches derived PDA + - Validate compressed address derivation (especially with CPI context) + - Set new address params in CPI instruction - If create_mint is None: - Hash existing compressed mint account - Set input with merkle context (tree, queue, leaf_index, proof) @@ -152,7 +161,6 @@ Packed accounts (remaining accounts): - Validate: mint authority matches signer - Calculate: sum recipient amounts with overflow protection - Update: mint supply += sum_amounts - - If SPL mint exists: mint equivalent tokens to pool via CPI - Create: compressed token accounts for each recipient **UpdateMintAuthority / UpdateFreezeAuthority:** @@ -161,10 +169,9 @@ Packed accounts (remaining accounts): **MintToCToken:** - Validate: mint authority matches signer - - Calculate: sum recipient amounts - - Update: mint supply += sum_amounts - - If SPL mint exists: mint to pool, then transfer to recipients - - If no SPL mint: directly update ctoken account balances + - Calculate: sum recipient amount + - Update: mint supply += amount + - Update ctoken account balance via decompress operation **UpdateMetadataField:** - Validate: metadata authority matches signer (defaults to mint authority) @@ -204,23 +211,34 @@ Packed accounts (remaining accounts): - `ProgramError::InvalidInstructionData` (error code: 3) - Failed to deserialize instruction data or invalid action configuration - `ProgramError::InvalidAccountData` (error code: 4) - Account validation failures (wrong program ownership, invalid PDA derivation) -- `ProgramError::InvalidArgument` (error code: 1) - Invalid authority or action parameters +- `ProgramError::NotEnoughAccountKeys` - Missing required accounts - `ErrorCode::MintActionProofMissing` (error code: 6055) - ZK proof required but not provided - `ErrorCode::InvalidAuthorityMint` (error code: 6018) - Signer doesn't match mint authority - `ErrorCode::MintActionAmountTooLarge` (error code: 6069) - Arithmetic overflow in mint amount calculations -- `ErrorCode::MintAccountMismatch` (error code: 6051) - SPL mint account doesn't match expected cmint +- `ErrorCode::MintAccountMismatch` (error code: 6051) - CMint account doesn't match expected mint - `ErrorCode::InvalidAddressTree` (error code: 6094) - Wrong address merkle tree for mint creation -- `ErrorCode::MintActionMissingSplMintSigner` (error code: 6045) - Missing mint signer for SPL mint creation -- `ErrorCode::MintActionMissingMintAccount` (error code: 6048) - Missing SPL mint account when required -- `ErrorCode::MintActionMissingTokenPoolAccount` (error code: 6049) - Missing token pool PDA when required -- `ErrorCode::MintActionMissingTokenProgram` (error code: 6050) - Missing token program when required +- `ErrorCode::MintActionMissingMintSigner` (error code: 6108) - Missing mint signer account +- `ErrorCode::MintActionMissingCMintAccount` (error code: 6109) - Missing CMint account for decompress mint action - `ErrorCode::MintActionInvalidExtensionIndex` (error code: 6059) - Extension index out of bounds - `ErrorCode::MintActionInvalidExtensionType` (error code: 6062) - Extension is not TokenMetadata type - `ErrorCode::MintActionMetadataKeyNotFound` (error code: 6063) - Metadata key not found for removal - `ErrorCode::MintActionMissingExecutingAccounts` (error code: 6064) - Missing required execution accounts +- `ErrorCode::MintActionInvalidMintPda` (error code: 6066) - Invalid mint PDA derivation +- `ErrorCode::MintActionOutputSerializationFailed` (error code: 6068) - Account data serialization failed +- `ErrorCode::MintActionInvalidInitialSupply` (error code: 6070) - Initial supply must be 0 for new mint creation +- `ErrorCode::MintActionUnsupportedVersion` (error code: 6071) - Mint version not supported +- `ErrorCode::MintActionInvalidCompressionState` (error code: 6072) - New mint must start as compressed +- `ErrorCode::MintActionUnsupportedOperation` (error code: 6073) - Unsupported operation - `ErrorCode::CpiContextExpected` (error code: 6085) - CPI context required but not provided -- `AccountError::InvalidSigner` (error code: 12015) - Required signer account is not signing -- `AccountError::NotEnoughAccountKeys` (error code: 12020) - Missing required accounts +- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Account index out of bounds for MintToCToken +- `ErrorCode::MintActionInvalidCpiContextForCreateMint` (error code: 6104) - Invalid CPI context for create mint operation +- `ErrorCode::MintActionInvalidCpiContextAddressTreePubkey` (error code: 6105) - Invalid address tree pubkey in CPI context +- `ErrorCode::MintActionInvalidCompressedMintAddress` (error code: 6103) - Invalid compressed mint address derivation +- `ErrorCode::MintDataRequired` (error code: 6125) - Mint data required in instruction when not decompressed +- `ErrorCode::CannotDecompressAndCloseInSameInstruction` (error code: 6123) - Cannot combine DecompressMint and CompressAndCloseCMint in same instruction +- `ErrorCode::CompressAndCloseCMintMustBeOnlyAction` (error code: 6169) - CompressAndCloseCMint must be the only action in the instruction +- `ErrorCode::CpiContextSetNotUsable` (error code: 6035) - Mint to ctokens or decompress mint not allowed when writing to CPI context +- `CTokenError::MaxTopUpExceeded` - Max top-up budget exceeded ### Spl mint migration - cmint to spl mint migration is unimplemented and not planned. diff --git a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md index 310de0dcd4..b96b6ed494 100644 --- a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -4,13 +4,13 @@ | I want to... | Go to | |-------------|-------| -| Transfer compressed tokens | → [Path B](#path-b-with-compressed-accounts-full-transfer-operations) (line 161) + [System accounts](#system-accounts-when-compressed-accounts-involved) (line 60) | -| Only compress/decompress (no transfers) | → [Path A](#path-a-no-compressed-accounts-compressions-only-operations) (line 134) + [Compressions-only accounts](#compressions-only-accounts-when-no_compressed_accounts) (line 99) | -| Compress SPL tokens | → [SPL compression](#spl-token-compressiondecompression) (line 217) | -| Compress CToken accounts | → [CToken compression](#ctoken-compressiondecompression-srctransfer2compressionctoken) (line 227) | -| Close compressible account (forester) | → [CompressAndClose](#for-compressandclose) (line 243) - compression_authority only | -| Use CPI context | → [Write mode](#cpi-context-write-path) (line 192) or [Execute mode](#cpi-context-support-for-cross-program-invocations) (line 27) | -| Debug errors | → [Error reference](#errors) (line 275) | +| Transfer compressed tokens | → [Path B](#path-b-with-compressed-accounts-full-transfer-operations) (line 184) + [System accounts](#system-accounts-when-compressed-accounts-involved) (line 77) | +| Only compress/decompress (no transfers) | → [Path A](#path-a-no-compressed-accounts-compressions-only-operations) (line 157) + [Compressions-only accounts](#compressions-only-accounts-when-no_compressed_accounts) (line 112) | +| Compress SPL tokens | → [SPL compression](#spl-token-compressiondecompression) (line 240) | +| Compress CToken accounts | → [CToken compression](#ctoken-compressiondecompression-srctransfer2compressionctoken) (line 250) | +| Close compressible account (forester) | → [CompressAndClose](#for-compressandclose) (line 274) - compression_authority only | +| Use CPI context | → [Write mode](#cpi-context-write-path) (line 215) or [Execute mode](#cpi-context-support-for-cross-program-invocations) (line 38) | +| Debug errors | → [Error reference](#errors) (line 329) | **discriminator:** 101 **enum:** `InstructionType::Transfer2` @@ -42,7 +42,13 @@ **Instruction data:** 1. instruction data is defined in path: program-libs/ctoken-interface/src/instructions/transfer2/instruction_data.rs - `with_transaction_hash`: Compute transaction hash for the complete transaction and include in compressed account data, enables ZK proofs over how compressed accounts are spent - - `with_lamports_change_account_merkle_tree_index`: Track lamport changes in specified tree + - `with_lamports_change_account_merkle_tree_index`: bool - Track lamport changes in specified tree (placeholder, unimplemented) + - `lamports_change_account_merkle_tree_index`: u8 - Merkle tree index for lamport change account (placeholder, unimplemented) + - `lamports_change_account_owner_index`: u8 - Owner index for lamport change account (placeholder, unimplemented) + - `output_queue`: u8 - Output queue index for compressed account outputs + - `max_top_up`: u16 - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) + - `cpi_context`: Optional CompressedCpiContext - Required for CPI operations; write mode: set either first_set_context or set_context (not both); execute mode: provide with all flags false + - `compressions`: Optional Vec - Compress/decompress operations - `proof`: Optional CompressedProof - Required for ZK validation of compressed inputs; not needed for proof by index or when no compressed inputs exist - `in_token_data`: Vec - Input compressed token accounts (packed: owner/delegate/mint are indices to packed accounts) with merkle context (root index, tree/queue indices, leaf index, proof-by-index bool) - `out_token_data`: Vec - Output compressed token accounts (packed: owner/delegate/mint/merkle_tree are indices to packed accounts) @@ -50,8 +56,6 @@ - `out_lamports`: Optional lamport amounts for output accounts (unimplemented) - `in_tlv`: Optional TLV data for input accounts (used for CompressedOnly extension during decompress) - `out_tlv`: Optional TLV data for output accounts (used for CompressedOnly extension during CompressAndClose) - - `compressions`: Optional Vec - Compress/decompress operations - - `cpi_context`: Optional CompressedCpiContext - Required for CPI operations; write mode: set either first_set_context or set_context (not both); execute mode: provide with all flags false 2. Compression struct fields (path: program-libs/ctoken-interface/src/instructions/transfer2/compression.rs): - `mode`: CompressionMode enum (Compress, Decompress, CompressAndClose) @@ -62,6 +66,7 @@ - `pool_account_index`: u8 - For SPL: pool account index; For CompressAndClose: rent_sponsor_index - `pool_index`: u8 - For SPL: pool index; For CompressAndClose: compressed_account_index - `bump`: u8 - For SPL: pool PDA bump; For CompressAndClose: destination_index + - `decimals`: u8 - For SPL: decimals for transfer_checked; For CompressAndClose: rent_sponsor_is_signer flag (non-zero = true) **Accounts:** 1. light_system_program @@ -74,46 +79,44 @@ System accounts (when compressed accounts involved): - (signer, mutable) - Pays transaction fees and rent for new compressed accounts -3. authority - - (signer) - - Transaction authority for system operations - -4. cpi_authority_pda - - PDA signer for CPI calls to light system program +3. cpi_authority_pda + - PDA for CPI calls to light system program - Seeds: [CPI_AUTHORITY_SEED] -5. registered_program_pda +4. registered_program_pda - Legacy account for program registration -6. account_compression_authority +5. account_compression_authority - Account compression authority PDA -7. account_compression_program +6. account_compression_program - Merkle tree account compression program -8. system_program +7. system_program - System program for account operations -9. sol_pool_pda (optional) +8. sol_pool_pda (optional) - (mutable) - Required when input_lamports != output_lamports - Handles lamport imbalances in compressed accounts -10. sol_decompression_recipient (optional) - - (mutable) - - Required when decompressing lamports (input_lamports < output_lamports) - - Receives decompressed SOL +9. sol_decompression_recipient (optional) + - (mutable) + - Required when decompressing lamports (input_lamports < output_lamports) + - Receives decompressed SOL -11. cpi_context_account (optional) +10. cpi_context_account (optional) - (mutable) - For storing CPI context data for later execution Compressions-only accounts (when no_compressed_accounts): -12. compressions_only_cpi_authority_pda - - PDA signer for compression operations +Note: In compressions-only mode, these accounts replace the system accounts above + +11. compressions_only_cpi_authority_pda + - PDA for compression operations - Seeds: [CPI_AUTHORITY_SEED] -13. compressions_only_fee_payer +12. compressions_only_fee_payer - (signer, mutable) - Pays for compression/decompression operations @@ -130,6 +133,7 @@ Packed accounts (dynamic indexing): - Deserialize `CompressedTokenInstructionDataTransfer2` using zero-copy - Validate CPI context via `check_cpi_context`: Ensures `set_context || first_set_context` is false when `cpi_context` is Some - Validate instruction data via `validate_instruction_data`: + - Check input accounts limit (max 8 input compressed accounts, error: TooManyInputAccounts) - Check unimplemented features (`in_lamports`, `out_lamports`) are None - Validate `in_tlv` length matches `in_token_data` length if provided - Validate `out_tlv` length matches `out_token_data` length if provided @@ -299,15 +303,15 @@ When compression processing occurs (in both Path A and Path B): - **Note:** `compress_to_pubkey` is stored in the compressible extension and set during account creation, not per-instruction - Mint: Must match the ctoken account's mint field - Version: Must be ShaFlat (version=3) for security - - Version: Must match the version specified in the token account's compressible extension - **Delegate/Frozen state handling (with CompressedOnly extension):** - - If account has `compression_only` flag set (restricted mint), CompressedOnly extension is REQUIRED in output TLV - - CompressedOnly extension preserves: `is_frozen`, `delegated_amount`, `delegate` (in token_data), `withheld_transfer_fee` + - If account has `compression_only` flag set (restricted mint) or `is_ata` flag set (ATA accounts), CompressedOnly extension is REQUIRED in output TLV + - CompressedOnly extension preserves: `is_frozen`, `delegated_amount`, `delegate` (in token_data), `withheld_transfer_fee`, `is_ata` - Delegate: Must match between ctoken.delegate and compressed output delegate - Delegated amount: Must match between ctoken.delegated_amount and extension.delegated_amount - Frozen state: Must match between ctoken.state==2 and extension.is_frozen - Withheld fee: Must match between ctoken TransferFeeAccount.withheld_amount and extension.withheld_transfer_fee - - Error: `CompressAndCloseDelegatedAmountMismatch`, `CompressAndCloseInvalidDelegate`, `CompressAndCloseFrozenMismatch`, `CompressAndCloseWithheldFeeMismatch` + - is_ata: Must match between compressible_extension.is_ata() and extension.is_ata() + - Error: `CompressAndCloseDelegatedAmountMismatch`, `CompressAndCloseInvalidDelegate`, `CompressAndCloseFrozenMismatch`, `CompressAndCloseWithheldFeeMismatch`, `CompressAndCloseIsAtaMismatch` - **Delegate handling (without CompressedOnly extension):** - Delegate: Must be None (has_delegate=false and delegate=0) - delegates cannot be carried over without extension - Error: `CompressAndCloseDelegateNotAllowed` if source has delegate or output has delegate @@ -318,9 +322,9 @@ When compression processing occurs (in both Path A and Path B): - **Uniqueness validation:** All CompressAndClose operations in a single instruction must use different compressed output account indices. Duplicate output indices are rejected to prevent fund theft attacks where a compression_authority could close multiple accounts but route all funds to a single compressed output - Calculate compressible extension top-up if present (returns Option) - **Transfer deduplication optimization:** - - Collects all transfers into a 40-element array indexed by account + - Collects all transfers into a 40-element array indexed by packed account index - Deduplicates transfers to same account by summing amounts - - Executes single `multi_transfer_lamports` CPI with deduplicated transfers (max 40, error: TooManyCompressionTransfers) + - Executes single `multi_transfer_lamports` CPI with deduplicated transfers (max 32 compressions per instruction, error: TooManyCompressionTransfers) **Errors:** @@ -340,12 +344,14 @@ When compression processing occurs (in both Path A and Path B): - `CTokenError::CompressInsufficientFunds` (error code: 18019) - Insufficient balance for compression - `CTokenError::InsufficientSupply` (error code: 18010) - Insufficient token supply for operation - `CTokenError::ArithmeticOverflow` (error code: 18003) - Arithmetic overflow in balance calculations +- `CTokenError::TooManyInputAccounts` (error code: 18038) - Too many input compressed accounts. Maximum 8 input accounts allowed per instruction +- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds sender's max_top_up limit - `ErrorCode::SumCheckFailed` (error code: 6005) - Input/output token amounts don't match - `ErrorCode::InputsOutOfOrder` (error code: 6038) - Sum inputs mint indices not in ascending order - `ErrorCode::TooManyMints` (error code: 6039) - Sum check, too many mints (max 5) - `ErrorCode::DuplicateMint` (error code: 6102) - Duplicate mint index detected in inputs, outputs, or compressions (same mint referenced by multiple indices or same index used multiple times) - `ErrorCode::ComputeOutputSumFailed` (error code: 6002) - Output mint not in inputs or compressions -- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Too many compression transfers. Maximum 40 transfers allowed per instruction +- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Too many compression transfers. Maximum 32 compressions allowed per instruction - `ErrorCode::NoInputsProvided` (error code: 6025) - No compressions provided in early exit path (no compressed accounts) - `ErrorCode::CompressionsOnlyMissingFeePayer` (error code: 6096) - Missing fee payer for compressions-only operations - `ErrorCode::CompressionsOnlyMissingCpiAuthority` (error code: 6097) - Missing CPI authority PDA for compressions-only operations @@ -360,13 +366,14 @@ When compression processing occurs (in both Path A and Path B): - `ErrorCode::CompressAndCloseAmountMismatch` (error code: 6090) - Compression amount must match the full token balance - `ErrorCode::CompressAndCloseBalanceMismatch` (error code: 6091) - Token account balance must match compressed output amount - `ErrorCode::CompressAndCloseDelegateNotAllowed` (error code: 6092) - Source token account has delegate OR compressed output has delegate (delegates not supported) -- `ErrorCode::CompressAndCloseInvalidVersion` (error code: 6093) - Compressed token version must be 3 (ShaFlat) and must match compressible extension's account_version +- `ErrorCode::CompressAndCloseInvalidVersion` (error code: 6093) - Compressed token version must be 3 (ShaFlat) - `ErrorCode::CompressAndCloseInvalidMint` (error code: 6132) - Compressed token mint does not match source token account mint - `ErrorCode::CompressAndCloseMissingCompressedOnlyExtension` (error code: 6133) - Missing required CompressedOnly extension for restricted mint or frozen account - `ErrorCode::CompressAndCloseDelegatedAmountMismatch` (error code: 6135) - Delegated amount mismatch between ctoken and CompressedOnly extension - `ErrorCode::CompressAndCloseInvalidDelegate` (error code: 6136) - Delegate mismatch between ctoken and compressed token output - `ErrorCode::CompressAndCloseWithheldFeeMismatch` (error code: 6137) - Withheld transfer fee mismatch - `ErrorCode::CompressAndCloseFrozenMismatch` (error code: 6138) - Frozen state mismatch between ctoken and CompressedOnly extension +- `ErrorCode::CompressAndCloseIsAtaMismatch` (error code: 6168) - is_ata mismatch between CompressibleExtension and CompressedOnly extension - `ErrorCode::CompressedOnlyRequiresCTokenDecompress` (error code: 6149) - CompressedOnly inputs must decompress to CToken account, not SPL token account - `ErrorCode::TlvRequiresVersion3` (error code: 6139) - TLV extensions only supported with version 3 (ShaFlat) - `ErrorCode::CompressAndCloseDuplicateOutput` (error code: 6106) - Cannot use the same compressed output account for multiple CompressAndClose operations (security protection against fund theft) diff --git a/programs/compressed-token/program/docs/compressible/CLAIM.md b/programs/compressed-token/program/docs/compressible/CLAIM.md index c642b2fa4d..0395f3fb12 100644 --- a/programs/compressed-token/program/docs/compressible/CLAIM.md +++ b/programs/compressed-token/program/docs/compressible/CLAIM.md @@ -9,21 +9,25 @@ 2. Supports both account types: - CToken (account_type = 2): decompressed token accounts, layout defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs - CMint (account_type = 1): decompressed mint accounts, layout defined in program-libs/ctoken-interface/src/state/mint/compressed_mint.rs -3. CompressionInfo is embedded directly in both account types (not as an extension), defined in program-libs/compressible/src/compression_info.rs +3. CompressionInfo storage differs by account type: + - CToken: CompressionInfo is stored inside a Compressible extension (not embedded directly) + - CMint: CompressionInfo is embedded directly in the mint struct at `compression` field + - CompressionInfo type defined in program-libs/compressible/src/compression_info.rs 4. Processes multiple token accounts in a single instruction for efficiency 5. For each eligible compressible account: - - Updates the account's RentConfig from the CompressibleConfig - - Updates the config_account_version to match current config version + - Validates config_account_version matches CompressibleConfig version - Calculates claimable rent based on completed epochs since last claim - - Updates the `last_claimed_slot` in the compressible extension + - Updates the `last_claimed_slot` in the CompressionInfo + - Updates the account's RentConfig from the CompressibleConfig (after claim calculation) - Transfers claimable lamports from token account to rent sponsor PDA -6. RentConfig is updated for ALL accounts with compressible extension (even those without claimable rent) -7. Only accounts with compressible extension can be claimed from +6. RentConfig is updated for ALL accounts that pass validation (even those without claimable rent) +7. CToken accounts must have Compressible extension; CMint accounts have CompressionInfo embedded directly 8. Only the compression authority (from CompressibleConfig) can execute claims 9. **Config validation:** Config must not be inactive (active or deprecated allowed) -10. Accounts that don't meet claim criteria are skipped without error -11. Only completed epochs are claimed, partial epochs remain with the account -12. The instruction is designed to be called periodically by foresters +10. Accounts that don't match compression_authority or rent_sponsor are skipped without error (returns None) +11. Accounts with mismatched config_account_version return error (CompressibleError::InvalidVersion) +12. Only completed epochs are claimed, partial epochs remain with the account +13. The instruction is designed to be called periodically by foresters **Instruction data:** - Empty (zero bytes required) @@ -50,9 +54,10 @@ 4. accounts (remaining accounts) - (mutable, variable number) - CToken or CMint accounts to claim rent from - - Account type determined by byte 165 (1 = CMint, 2 = CToken) or size (165 bytes = CToken) + - Account type determined by size: exactly 165 bytes = CToken, otherwise read byte 165 (1 = CMint, 2 = CToken) - Each account is processed independently - - Invalid accounts (wrong authority/recipient/type) are skipped without error + - Accounts with wrong compression_authority or rent_sponsor are skipped without error (returns None) + - Accounts with wrong owner, invalid size, or invalid type discriminator return error **Instruction Logic and Checks:** @@ -74,52 +79,55 @@ 4. **Process each account:** For each account in remaining accounts: - a. **Determine account type:** - - If account size < 165 bytes: invalid, skip - - If account size == 165 bytes: CToken (legacy) + a. **Verify account ownership:** + - Account must be owned by the compressed token program + - Uses `check_owner` with CTOKEN program ID + + b. **Determine account type:** + - If account size < 165 bytes: invalid, return error + - If account size == 165 bytes: CToken (legacy size without extensions) - If account size > 165 bytes: read byte 165 for discriminator (1 = CMint, 2 = CToken) - b. **Parse account data:** + c. **Parse account data:** - Borrow mutable data - Deserialize as CToken or CMint based on account type with zero-copy - - c. **Validate compression info:** - - Access embedded CompressionInfo from account - - Validate compression_authority matches - - Validate rent_sponsor matches - - d. **Validate version:** - - Verify `compression.config_account_version` matches CompressibleConfig version - - Error with `CompressibleError::InvalidVersion` if versions don't match (prevents cross-version claims) - - e. **Calculate and claim rent:** - - Get account size and current lamports - - Calculate rent exemption for account size - - Call `compression.claim()` which: - - Determines completed epochs since last claim using CURRENT RentConfig - - Calculates claimable lamports - - Updates last_claimed_slot if there's claimable rent - - Returns None if no rent to claim (account not yet compressible) - - After claim calculation, always update `compression.rent_config` from CompressibleConfig for future operations - - f. **Transfer lamports:** + - For CToken: uses `CToken::zero_copy_at_mut_checked()` then `get_compressible_extension_mut()` + - For CMint: uses `CompressedMint::zero_copy_at_mut_checked()` then accesses `base.compression` + + d. **Call claim_and_update (in CompressionInfo):** + - Validate compression_authority matches (returns None if mismatch, skips account) + - Validate rent_sponsor matches (returns None if mismatch, skips account) + - Verify `config_account_version` matches CompressibleConfig version + - Returns `CompressibleError::InvalidVersion` error if versions don't match + - Call internal `claim()` method which: + - Calculates claimable lamports based on completed epochs + - Updates `last_claimed_slot` if there's claimable rent + - Returns claimed amount or None if nothing to claim + - Always update `rent_config` from CompressibleConfig (even if claim returned None) + + e. **Transfer lamports:** - If claim amount > 0, transfer from account to rent_sponsor - - Update both account balances + - Uses `transfer_lamports` helper function 5. **Complete successfully:** - - All valid accounts processed - - Invalid accounts silently skipped + - All accounts processed + - Accounts with mismatched compression_authority/rent_sponsor are skipped (no error) **Errors:** - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not empty -- `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig/CToken deserialization fails, account type discriminator invalid, or claim calculation fails -- `ErrorCode::InvalidCompressAuthority` - compression_authority doesn't match CompressibleConfig -- `ErrorCode::InvalidRentSponsor` - rent_sponsor doesn't match CompressibleConfig -- `CompressibleError::InvalidVersion` (error code: 19003) - Account's config_account_version doesn't match CompressibleConfig version +- `ProgramError::InvalidAccountData` (error code: 4) - CompressibleConfig/CToken/CMint deserialization fails, account size < 165 bytes, or account type discriminator invalid +- `ErrorCode::InvalidCompressAuthority` - compression_authority doesn't match CompressibleConfig (fixed account validation) +- `ErrorCode::InvalidRentSponsor` - rent_sponsor doesn't match CompressibleConfig (fixed account validation) +- `CompressibleError::InvalidVersion` (error code: 19003) - Account's config_account_version doesn't match CompressibleConfig version (per-account validation) - `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account lacks required Compressible extension -- `AccountError::NotEnoughAccountKeys` (error code: 20014) - Missing required accounts +- `AccountError::NotEnoughAccountKeys` (error code: 20014) - Missing required fixed accounts (rent_sponsor, compression_authority, config) - `AccountError::InvalidSigner` (error code: 20009) - compression_authority is not a signer - `AccountError::AccountNotMutable` (error code: 20002) - rent_sponsor is not mutable -- `AccountError::AccountOwnedByWrongProgram` (error code: 20001) - Token account not owned by compressed token program +- `AccountError::AccountOwnedByWrongProgram` (error code: 20001) - Token/Mint account not owned by compressed token program - `CompressibleError::InvalidState` (error code: 19002) - CompressibleConfig is in inactive state + +**Note on error vs skip behavior:** +- Fixed account validation errors (compression_authority, rent_sponsor, config) cause instruction failure +- Per-account compression_authority/rent_sponsor mismatch causes that account to be skipped (returns None) +- Per-account config version mismatch causes instruction failure with InvalidVersion error diff --git a/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md b/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md index b87d71d186..ff89f1d2e3 100644 --- a/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md +++ b/programs/compressed-token/program/docs/compressible/WITHDRAW_FUNDING_POOL.md @@ -9,9 +9,9 @@ 2. The rent_sponsor PDA holds funds collected from rent claims and compression incentives 3. Only the compression_authority from CompressibleConfig can execute withdrawals 4. **Config validation:** Config must not be inactive (active or deprecated allowed) -5. The rent_sponsor PDA is derived from ["rent_sponsor", version_bytes, bump] where version comes from CompressibleConfig +5. The rent_sponsor PDA is derived from ["rent_sponsor", version_bytes, bump] where version is a u16 from CompressibleConfig serialized as little-endian bytes 6. Enables protocol operators to manage collected rent and redirect funds for operational needs -7. The instruction validates PDA derivation matches the config's rent_sponsor +7. The instruction validates rent_sponsor and compression_authority match the config **Instruction data:** - First 8 bytes: withdrawal amount (u64, little-endian) @@ -38,7 +38,7 @@ 4. system_program - (non-mutable) - System program for lamport transfer - - Required for system_instruction::transfer + - Required for pinocchio_system Transfer instruction 5. config - (non-mutable) @@ -54,14 +54,15 @@ - Error if instruction data length < 8 bytes 2. **Validate and parse accounts:** - - Parse all required accounts with correct mutability + - Parse all required accounts with correct mutability using AccountIterator - Verify compression_authority is signer - Parse and validate CompressibleConfig: - - Deserialize using parse_config_account helper + - Check owner is Registry program + - Validate discriminator and deserialize using bytemuck - Check config is not inactive (validate_not_inactive) - Verify compression_authority matches config - Verify rent_sponsor matches config - - Extract rent_sponsor_bump and version for PDA derivation + - Extract rent_sponsor_bump and version (u16 as little-endian bytes) for PDA derivation 3. **Verify sufficient funds:** - Get current pool balance from rent_sponsor.lamports() @@ -69,8 +70,8 @@ - Error if insufficient funds 4. **Execute transfer:** - - Create system_instruction::transfer from rent_sponsor to destination - - Prepare PDA signer seeds: ["rent_sponsor", version_bytes, bump] + - Create pinocchio_system Transfer struct from rent_sponsor to destination + - Prepare PDA signer seeds: [b"rent_sponsor", version_bytes (2 bytes), bump (1 byte)] - Invoke system program with PDA as signer using invoke_signed - Transfer specified amount to destination diff --git a/programs/compressed-token/program/docs/ctoken/APPROVE.md b/programs/compressed-token/program/docs/ctoken/APPROVE.md index dfeadfe66b..5a735f0845 100644 --- a/programs/compressed-token/program/docs/ctoken/APPROVE.md +++ b/programs/compressed-token/program/docs/ctoken/APPROVE.md @@ -14,10 +14,10 @@ If the CToken account has a compressible extension and requires a rent top-up, t - **NOT SPL-compatible (system program required):** Compressible accounts that need rent top-up based on current slot **description:** -Delegates a specified amount to a delegate authority on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). Before the approve operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible approve semantics. Supports backwards-compatible instruction data format (8 bytes legacy vs 10 bytes with max_top_up). +Delegates a specified amount to a delegate authority on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). After the SPL approve operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible approve semantics. Supports backwards-compatible instruction data format (8 bytes legacy vs 10 bytes with max_top_up). **Instruction data:** -Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 34-66) +Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 14-15, 98-106) - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to delegate - Bytes 8-9 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit, default for legacy format) @@ -41,41 +41,38 @@ Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 34-6 **Instruction Logic and Checks:** -1. **Validate minimum accounts:** - - Require source account (index 0) and owner account (index 2) - - Return NotEnoughAccountKeys if either account is missing - - Note: delegate (index 1) is validated by pinocchio during SPL approve - -2. **Parse instruction data:** - - If 8 bytes: legacy format, set max_top_up = 0 (no limit) - - If 10 bytes: parse amount (first 8 bytes) and max_top_up (last 2 bytes) - - Return InvalidInstructionData for any other length - -3. **Process compressible top-up:** - - Borrow source account data mutably - - Deserialize CToken using zero-copy validation - - Initialize lamports_budget based on max_top_up: - - If max_top_up == 0: budget = u64::MAX (no limit) - - Otherwise: budget = max_top_up + 1 (allows exact match) - - Call process_compression_top_up with source account's compression info - - Drop borrow before CPI - - If transfer_amount > 0: - - Check that transfer_amount <= lamports_budget - - Return MaxTopUpExceeded if budget exceeded - - Transfer lamports from owner to source via CPI +1. **Validate minimum accounts and instruction data:** + - Return NotEnoughAccountKeys if accounts array is empty + - Return InvalidInstructionData if instruction data is less than 8 bytes + - Note: delegate (index 1) and owner (index 2) are validated by pinocchio during SPL approve -4. **Process SPL approve:** +2. **Process SPL approve:** - Pass only first 8 bytes (amount) to pinocchio-token-program - Call process_approve with accounts and amount data - Delegate is granted spending rights for the specified amount +3. **Handle compressible top-up (hot path optimization):** + - If source account data length is exactly 165 bytes, skip top-up (no extensions) + - Otherwise, call process_compressible_top_up + +4. **Process compressible top-up (cold path):** + - Parse max_top_up from instruction data: + - If 8 bytes: legacy format, set max_top_up = 0 (no limit) + - If 10 bytes: parse max_top_up from last 2 bytes + - Return InvalidInstructionData for any other length + - Read CompressionInfo directly from account bytes using bytemuck (no full CToken deserialization) + - Calculate transfer_amount using `top_up_lamports_from_account_info_unchecked` + - If transfer_amount > 0: + - If max_top_up > 0 and transfer_amount > max_top_up: return MaxTopUpExceeded + - Get payer account (index 2), return MissingPayer if not present + - Transfer lamports from payer to source via CPI + **Errors:** - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 8 or 10 bytes -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- `ProgramError::NotEnoughAccountKeys` (error code: 11) - No accounts provided - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up parameter +- `CTokenError::MissingPayer` (error code: 18061) - Payer account (index 2) not provided when top-up is required - `ProgramError::MissingRequiredSignature` (error code: 8) - Owner did not sign the transaction (SPL Token error) - Pinocchio token errors (converted to ProgramError::Custom): - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match account owner @@ -102,27 +99,31 @@ CToken Approve maintains compatibility with SPL Token-2022's core approve functi **1. Compressible Extension Top-Up Logic** -CToken Approve includes automatic rent top-up for accounts with the Compressible extension: +CToken Approve includes automatic rent top-up for accounts with the Compressible extension. The top-up happens AFTER the SPL approve operation: ```rust -// Before SPL approve operation -process_compression_top_up( - &ctoken.base.compression, - account, - &mut 0, - &mut transfer_amount, - &mut lamports_budget, -)?; - -// Transfer lamports from owner to source if needed +// After SPL approve operation succeeds +// Hot path: 165-byte accounts have no extensions, skip top-up +if source.data_len() == 165 { + return Ok(()); +} + +// Cold path: calculate and transfer top-up if needed +let transfer_amount = top_up_lamports_from_account_info_unchecked(account, &mut current_slot) + .unwrap_or(0); + if transfer_amount > 0 { + if max_top_up > 0 && transfer_amount > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + let payer = payer.ok_or(CTokenError::MissingPayer)?; transfer_lamports_via_cpi(transfer_amount, payer, account)?; } ``` **Purpose**: Prevents accounts from becoming compressible during normal operations by maintaining minimum rent balance. -**Reference**: See `/home/ananas/dev/light-protocol/program-libs/compressible/docs/RENT.md` for rent calculation details. +**Reference**: See `program-libs/compressible/docs/RENT.md` for rent calculation details. **2. max_top_up Parameter** @@ -132,14 +133,8 @@ Extended instruction data format (10 bytes total): **Enforcement**: ```rust -let lamports_budget = if max_top_up == 0 { - u64::MAX // No limit -} else { - (max_top_up as u64).saturating_add(1) // Allow exact match -}; - -if lamports_budget != 0 && transfer_amount > lamports_budget { - return Err(CTokenError::MaxTopUpExceeded); +if max_top_up > 0 && transfer_amount > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); } ``` @@ -152,4 +147,4 @@ if lamports_budget != 0 && transfer_amount > lamports_budget { ### Related Instructions -**ApproveChecked:** CToken implements CTokenApproveChecked (discriminator: 13) with full decimals validation. See `CTOKEN_APPROVE_CHECKED.md`. +**Note:** Unlike SPL Token/Token-2022, CToken does NOT implement ApproveChecked (discriminator 13). Only the basic Approve instruction is supported. diff --git a/programs/compressed-token/program/docs/ctoken/APPROVE_CHECKED.md b/programs/compressed-token/program/docs/ctoken/APPROVE_CHECKED.md deleted file mode 100644 index 54ff45369a..0000000000 --- a/programs/compressed-token/program/docs/ctoken/APPROVE_CHECKED.md +++ /dev/null @@ -1,120 +0,0 @@ -## CToken ApproveChecked - -**discriminator:** 13 -**enum:** `InstructionType::CTokenApproveChecked` -**path:** programs/compressed-token/program/src/ctoken/approve_revoke.rs - -**description:** -Delegates a specified amount to a delegate authority on a decompressed ctoken account with decimals validation, fully compatible with SPL Token ApproveChecked semantics. Account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. Extension layout `CompressionInfo` is defined in program-libs/compressible/src/compression_info.rs. Uses pinocchio-token-program to process the approve operation. Before the approve operation, automatically tops up compressible accounts with additional lamports if needed to prevent accounts from becoming compressible during normal operations. Supports max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses cached decimals optimization: if source CToken has cached decimals, validates against instruction decimals and skips mint read. Cached decimals allow users to choose whether a cmint is required to be decompressed at account creation or transfer. - -**Instruction data:** -Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 163-217) - -- Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to delegate -- Byte 8: `decimals` (u8) - Expected token decimals -- Bytes 9-10 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit) - -Format variants: -- 9 bytes: amount + decimals (legacy, no max_top_up enforcement) -- 11 bytes: amount + decimals + max_top_up - -**Accounts:** -1. source - - (mutable) - - The source ctoken account to approve delegation on - - May receive rent top-up if compressible - - May have cached decimals for validation optimization - -2. mint - - (immutable) - - The mint account for the token - - Must match source account's mint - - Decimals field must match instruction data decimals parameter - - Only read if source account has no cached decimals - -3. delegate - - (immutable) - - The delegate authority who will be granted spending rights - - Does not need to sign - -4. owner - - (signer, mutable) - - Owner of the source account - - Must sign the transaction - - Acts as payer for rent top-up if compressible extension present - -**Instruction Logic and Checks:** - -1. **Validate minimum accounts:** - - Require at least 4 accounts (source, mint, delegate, owner) - - Return NotEnoughAccountKeys if insufficient - -2. **Parse instruction data:** - - Require at least 9 bytes (amount + decimals) - - Parse amount (u64) and decimals (u8) using unpack_amount_and_decimals - - If 11 bytes: parse max_top_up from bytes 9-10 - - If 9 bytes: set max_top_up = 0 (no limit) - - Return InvalidInstructionData for any other length - -3. **Get cached decimals and process compressible top-up:** - - Borrow source account data mutably - - Deserialize CToken using zero-copy validation - - Get cached decimals via `ctoken.base.decimals()` (returns Option) - - Initialize lamports_budget based on max_top_up: - - If max_top_up == 0: budget = u64::MAX (no limit) - - Otherwise: budget = max_top_up + 1 (allows exact match) - - Call process_compression_top_up with source account's compression info - - Drop borrow before CPI - - If transfer_amount > 0: - - Check that transfer_amount <= lamports_budget - - Return MaxTopUpExceeded if budget exceeded - - Transfer lamports from owner to source via CPI - -4. **Process SPL approve based on cached decimals:** - - **If cached decimals present:** - - Validate cached_decimals == instruction decimals - - Return InvalidInstructionData if mismatch - - Create 3-account slice [source, delegate, owner] (skip mint) - - Call process_approve with expected_decimals = None (skip pinocchio mint validation) - - **If no cached decimals:** - - Validate mint is owned by valid token program (SPL, Token-2022, or CToken) - - Call process_approve with full 4-account layout and expected_decimals = Some(decimals) - -**Errors:** - -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 4 accounts provided -- `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 9 or 11 bytes, or cached decimals != instruction decimals -- `ProgramError::IncorrectProgramId` (error code: 7) - Mint is not owned by a valid token program (when no cached decimals) -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation -- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up parameter -- `ProgramError::MissingRequiredSignature` (error code: 8) - Owner did not sign the transaction (SPL Token error) -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match account owner - - `TokenError::AccountFrozen` (error code: 17) - Account is frozen - - `TokenError::MintMismatch` (error code: 3) - Mint doesn't match source account's mint - - `TokenError::MintDecimalsMismatch` (error code: 18) - Decimals don't match mint's decimals - -## Comparison with Token-2022 - -### Functional Parity - -CToken ApproveChecked maintains compatibility with SPL Token-2022's ApproveChecked: - -- **Delegate Authorization**: Both delegate spending authority to a delegate pubkey for a specified token amount -- **Owner Signature**: Transaction must be signed by the account owner (single owner only, no multisig support in CToken) -- **Account State Validation**: Both check that the source account is initialized and not frozen -- **Decimals Validation**: Both validate instruction decimals against mint decimals - -### CToken-Specific Features - -1. **Cached Decimals Optimization**: If source CToken has cached decimals, validates against instruction and skips mint read -2. **Compressible Top-Up Logic**: Automatically tops up accounts with the Compressible extension -3. **max_top_up Parameter**: Limits rent top-up costs (0 = no limit) -4. **Static 4-Account Layout**: Always requires mint account, but may skip reading it when cached decimals are available - - -### Unsupported SPL & Token-2022 Features - -**1. No Multisig Support** -**2. No CPI Guard Extension Check** diff --git a/programs/compressed-token/program/docs/ctoken/BURN.md b/programs/compressed-token/program/docs/ctoken/BURN.md index 43d168ed1b..93843030e7 100644 --- a/programs/compressed-token/program/docs/ctoken/BURN.md +++ b/programs/compressed-token/program/docs/ctoken/BURN.md @@ -67,20 +67,23 @@ Format 2 (10 bytes): - Initialize lamports_budget to max_top_up + 1 (allowing exact match when total == max_top_up) b. **Calculate CMint top-up:** - - Borrow CMint data and deserialize using `CompressedMint::zero_copy_at` - - Access compression info directly from mint.base.compression (embedded in all CMints) - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded (current_slot == 0) - - Call `compression.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call optimized `cmint_top_up_lamports_from_account_info(cmint, current_slot, program_id)` + - Verifies account owner matches expected program ID + - Validates minimum size (262 bytes) and account_type field + - Reads CompressionInfo directly from fixed byte offset (166) + - Lazy loads Clock sysvar if current_slot == 0 + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Returns None if any validation fails (no error, gracefully skipped) + - Subtracts calculated top-up from lamports_budget c. **Calculate CToken top-up:** - - Skip if CToken data length is 165 bytes (no extensions, standard SPL token account) - - Borrow CToken data and deserialize using `CToken::zero_copy_at_checked` - - Get Compressible extension via `token.get_compressible_extension()` - - Fail with MissingCompressibleExtension if CToken has extensions but no Compressible extension - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded (current_slot == 0) - - Call `compressible.info.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call optimized `top_up_lamports_from_account_info_unchecked(ctoken, current_slot)` + - Validates minimum size (272 bytes), account_type, Option discriminator, and first extension type + - Reads CompressionInfo directly from fixed byte offset (176) + - Returns None if CToken is 165 bytes (no extensions) or lacks Compressible as first extension + - Lazy loads Clock sysvar if current_slot == 0 + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Subtracts calculated top-up from lamports_budget d. **Validate budget:** - If no compressible accounts were found (current_slot == 0), exit early @@ -88,6 +91,7 @@ Format 2 (10 bytes): - If max_top_up != 0 and lamports_budget == 0, fail with MaxTopUpExceeded e. **Execute transfers:** + - Fail with MissingPayer if payer account is not provided - Call `multi_transfer_lamports(payer, &transfers)` to atomically transfer lamports - Updates account balances for both CMint and CToken if needed @@ -96,18 +100,12 @@ Format 2 (10 bytes): - `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data length is not 8 or 10 bytes - `ProgramError::InsufficientFunds` (error code: 6) - Source CToken balance less than burn amount (from pinocchio burn), or payer has insufficient funds for top-up transfers -- `ProgramError::ArithmeticOverflow` (error code: 24) - Overflow when calculating total top-up amount - Pinocchio token errors (converted to ProgramError::Custom): - `TokenError::OwnerMismatch` (error code: 4) - Authority is not owner or delegate - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Account data length is too small, calculate top-up failed, or invalid account format -- `CTokenError::InvalidAccountState` (error code: 18036) - CToken account is not initialized (from zero-copy parsing) -- `CTokenError::InvalidAccountType` (error code: 18053) - Account is not a CToken account type (from zero-copy parsing) -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account has extensions but missing required Compressible extension +- `CTokenError::MissingPayer` (error code: 18061) - Payer account not provided but top-up is required ## Comparison with Token-2022 diff --git a/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md b/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md index ff170ea7be..9ea1e627d7 100644 --- a/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/BURN_CHECKED.md @@ -71,25 +71,33 @@ Format 2 (11 bytes): - Initialize lamports_budget to max_top_up + 1 (allowing exact match when total == max_top_up) b. **Calculate CMint top-up:** - - Borrow CMint data and deserialize using `CompressedMint::zero_copy_at` - - Access compression info directly from mint.base.compression - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded - - Call `compression.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call `cmint_top_up_lamports_from_account_info(cmint, current_slot, program_id)` + - Verifies CMint is owned by the expected program + - Checks data length >= 262 bytes (minimum for CMint with CompressionInfo) + - Validates account_type byte at offset 165 is ACCOUNT_TYPE_MINT + - Reads CompressionInfo directly from bytes using `CompressionInfo::zero_copy_at` + - Lazy loads Clock sysvar for current_slot if needed + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Returns None (skip) if any validation fails + - Subtracts calculated top-up from lamports_budget c. **Calculate CToken top-up:** - - Skip if CToken data length is 165 bytes (no extensions, standard SPL token account) - - Borrow CToken data and deserialize using `CToken::zero_copy_at_checked` - - Get Compressible extension via `token.get_compressible_extension()` - - Fail with MissingCompressibleExtension if CToken has extensions but no Compressible extension - - Lazy load Clock sysvar for current_slot and Rent sysvar if not yet loaded - - Call `compressible.info.calculate_top_up_lamports(data_len, current_slot, lamports, rent_exemption)` - - Subtract calculated top-up from lamports_budget + - Call `top_up_lamports_from_account_info_unchecked(ctoken, current_slot)` + - Returns None (skip) if CToken data length < 272 bytes (minimum for Compressible extension) + - Validates account_type byte at offset 165 is ACCOUNT_TYPE_TOKEN_ACCOUNT + - Validates Option discriminator at offset 166 is Some + - Validates first extension discriminator at offset 171 is Compressible + - Reads CompressionInfo directly via bytemuck from bytes at offset 176 + - Lazy loads Clock sysvar for current_slot if needed + - Calls `calculate_top_up_lamports(data_len, current_slot, lamports)` + - Returns None (skip) if any validation fails + - Subtracts calculated top-up from lamports_budget d. **Validate budget:** - If no compressible accounts were found (current_slot == 0), exit early - If both top-up amounts are 0, exit early - If max_top_up != 0 and lamports_budget == 0, fail with MaxTopUpExceeded + - If payer is None but top-up is needed, fail with MissingPayer e. **Execute transfers:** - Call `multi_transfer_lamports(payer, &transfers)` to atomically transfer lamports @@ -100,19 +108,13 @@ Format 2 (11 bytes): - `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data length is not 9 or 11 bytes - `ProgramError::InsufficientFunds` (error code: 6) - Source CToken balance less than burn amount (from pinocchio burn), or payer has insufficient funds for top-up transfers -- `ProgramError::ArithmeticOverflow` (error code: 24) - Overflow when calculating total top-up amount -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::OwnerMismatch` (error code: 4) - Authority is not owner or delegate - - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - - `TokenError::MintDecimalsMismatch` (error code: 18) - Decimals don't match CMint's decimals - - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Account data length is too small, calculate top-up failed, or invalid account format -- `CTokenError::InvalidAccountState` (error code: 18036) - CToken account is not initialized (from zero-copy parsing) -- `CTokenError::InvalidAccountType` (error code: 18053) - Account is not a CToken account type (from zero-copy parsing) -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- Pinocchio token errors (converted to ErrorCode variants via convert_pinocchio_token_error): + - `ErrorCode::OwnerMismatch` - Authority is not owner or delegate + - `ErrorCode::MintMismatch` - CToken mint doesn't match CMint + - `ErrorCode::MintDecimalsMismatch` - Decimals don't match CMint's decimals + - `ErrorCode::AccountFrozen` - CToken account is frozen - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account has extensions but missing required Compressible extension +- `CTokenError::MissingPayer` (error code: 18061) - Payer account required for top-up but not provided ## Comparison with Token-2022 @@ -167,7 +169,7 @@ CToken BurnChecked implements similar core functionality to SPL Token-2022's Bur 3. **Decimals Validation:** - Pinocchio validates instruction decimals against CMint's decimals field - - Returns MintDecimalsMismatch (error code: 18) on mismatch + - Returns ErrorCode::MintDecimalsMismatch on mismatch ### Security Properties diff --git a/programs/compressed-token/program/docs/ctoken/CLOSE.md b/programs/compressed-token/program/docs/ctoken/CLOSE.md index 9836104503..9b179d0535 100644 --- a/programs/compressed-token/program/docs/ctoken/CLOSE.md +++ b/programs/compressed-token/program/docs/ctoken/CLOSE.md @@ -1,7 +1,7 @@ ## Close Token Account **discriminator:** 9 -**enum:** `CTokenInstruction::CloseTokenAccount` +**enum:** `InstructionType::CloseTokenAccount` **path:** programs/compressed-token/program/src/ctoken/close/ **description:** @@ -9,8 +9,8 @@ 2. Account layout `CToken` is defined in path: program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs 3. Supports both regular (non-compressible) and compressible token accounts (with compressible extension) 4. For compressible accounts (with compressible extension): - - Rent exemption + unclaimed rent lamports are returned to the rent_sponsor - - Remaining user lamports are returned to the destination account + - Completed epoch rent lamports are returned to the rent_sponsor + - Partial/unutilized epoch lamports are returned to the destination account (user) - Only the owner or close_authority (if set) can close using this instruction (balance must be zero) - **Note:** To compress and close with non-zero balance, use CompressAndClose mode in Transfer2 (compression_authority only) - **Note:** It is impossible to set a close authority. @@ -45,31 +45,29 @@ 4. rent_sponsor (optional, required for compressible accounts) - (mutable) - - Receives rent exemption + unclaimed rent for compressible accounts + - Receives completed epoch rent lamports for compressible accounts - Must match the rent_sponsor field in the compressible extension - Not required for non-compressible accounts (only 3 accounts needed) **Instruction Logic and Checks:** 1. **Parse and validate accounts** (`validate_and_parse` in `accounts.rs`): - - Extract token_account (index 0), destination (index 1), authority (index 2) - - Extract rent_sponsor (index 3) if accounts.len() >= 4 (required for compressible accounts) - - Verify token_account is mutable via `check_mut` + - Extract token_account (index 0) via `iter.next_mut()` (validates mutability) - Verify token_account is owned by ctoken program via `check_owner` - - Verify destination is mutable via `check_mut` - - Verify authority is a signer via `check_signer` - - If rent_sponsor provided: verify rent_sponsor is mutable via `check_mut` + - Extract destination (index 1) via `iter.next_mut()` (validates mutability) + - Extract authority (index 2) via `iter.next_signer()` (validates signer) + - If accounts.len() >= 4: extract rent_sponsor (index 3) via `iter.next_mut()` (validates mutability) 2. **Deserialize and validate token account** (`process_close_token_account` in `processor.rs`): - Borrow token account data mutably - - Parse as `CToken` using `zero_copy_at_mut_checked` (validates initialized state and account type) - - Call `validate_token_account` (CHECK_RENT_AUTH=false for regular close) + - Parse as `CToken` using `CToken::from_account_info_mut_checked` (validates program ownership, initialized state and account type) + - Call `validate_token_account_close` to validate closure requirements -3. **Validate closure requirements** (`validate_token_account`): +3. **Validate closure requirements** (`validate_token_account_close` in `processor.rs`): 3.1. **Basic validation**: - Verify token_account.key() != destination.key() (prevents self-transfer) - 3.2. **Balance check** (only when COMPRESS_AND_CLOSE=false): + 3.2. **Balance check**: - Convert ctoken.amount from U64 to u64 - Verify amount == 0 (non-zero returns `ErrorCode::NonNativeHasBalance`) @@ -80,7 +78,8 @@ - Fall through to close_authority/owner check (compression_authority cannot use this instruction) 3.4. **Account state check**: - - Check account state field equals AccountState::Initialized (value 1): + - Note: `from_account_info_mut_checked` already validates that state == Initialized (1) + - Additional validation for frozen/uninitialized states (redundant but explicit): - If state == AccountState::Frozen (value 2): return `ErrorCode::AccountFrozen` - If state is any other value: return `ProgramError::UninitializedAccount` @@ -97,25 +96,21 @@ 4.2. **Check for compressible extension**: - Borrow token account data (read-only this time) - - Parse as CToken using `zero_copy_at_checked` - - Look for `ZExtensionStruct::Compressible` in extensions + - Parse as CToken using `CToken::from_account_info_checked` + - Look for `ZExtensionStruct::Compressible` in extensions via `get_compressible_extension()` 4.3. **For compressible accounts** (if extension found): - Get current_slot from Clock::get() sysvar - - Calculate base_lamports using `get_rent_exemption_lamports(account.data_len)` + - Calculate base_lamports from `compression.info.rent_exemption_paid` - Create `AccountRentState` with: - num_bytes, current_slot, current_lamports, last_claimed_slot - Call `calculate_close_distribution` with: - rent_config, base_lamports - Returns `CloseDistribution { to_rent_sponsor, to_user }` - Get rent_sponsor account from accounts (error if missing) - - For regular close (owner/close_authority): - - Transfer to_rent_sponsor lamports to rent_sponsor via `transfer_lamports` (if > 0) - - Transfer to_user lamports to destination via `transfer_lamports` (if > 0) - - For CompressAndClose (compression_authority in Transfer2): - - Extract compression_cost from rent_sponsor portion as forester reward - - Add to_user to rent_sponsor portion (unused funds go to rent_sponsor) - - Transfer adjusted lamports to rent_sponsor and compression_cost to destination (forester) + - Check if authority is compression_authority: + - If compression_authority: Extract compression_cost from rent_sponsor portion as forester reward, add to_user to rent_sponsor portion (unused funds go to rent_sponsor), transfer adjusted lamports to rent_sponsor and compression_cost to destination (forester) + - Otherwise (owner/close_authority): Transfer to_rent_sponsor lamports to rent_sponsor via `transfer_lamports` (if > 0), transfer to_user lamports to destination via `transfer_lamports` (if > 0) - Return early (skip non-compressible path) 4.4. **For non-compressible accounts**: @@ -132,17 +127,30 @@ - Maps resize error to ProgramError::Custom if fails **Errors:** -- `ProgramError::InvalidAccountData` (error code: 4) - token_account == destination, rent_sponsor doesn't match extension, compression_authority mismatch, or account not compressible -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Missing rent_sponsor account for compressible accounts -- `AccountError::InvalidSigner` (error code: 12015) - Authority is not a signer + +*Account parsing errors (from `validate_and_parse`):* - `AccountError::AccountNotMutable` (error code: 12008) - token_account, destination, or rent_sponsor is not mutable +- `AccountError::InvalidSigner` (error code: 12015) - Authority is not a signer - `AccountError::AccountOwnedByWrongProgram` (error code: 12007) - token_account is not owned by ctoken program - `AccountError::NotEnoughAccountKeys` (error code: 12020) - Not enough accounts provided + +*CToken deserialization errors (from `from_account_info_mut_checked`):* +- `CTokenError::InvalidCTokenOwner` (error code: 18063) - token_account not owned by ctoken program +- `CTokenError::BorrowFailed` (error code: 18062) - Failed to borrow account data +- `CTokenError::InvalidAccountState` (error code: 18036) - Account state is not Initialized (state != 1) +- `CTokenError::InvalidAccountType` (error code: 18053) - Account type discriminator is invalid +- `CTokenError::InvalidAccountData` (error code: 18002) - Account has trailing bytes after CToken structure + +*Validation errors (from `validate_token_account_close`):* +- `ProgramError::InvalidAccountData` (error code: 4) - token_account == destination, or rent_sponsor doesn't match extension +- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Missing rent_sponsor account for compressible accounts +- `ErrorCode::NonNativeHasBalance` (error code: 6074) - Account has non-zero token balance - `ErrorCode::AccountFrozen` (error code: 6076) - Account state is Frozen - `ProgramError::UninitializedAccount` (error code: 10) - Account state is Uninitialized or invalid -- `ErrorCode::NonNativeHasBalance` (error code: 6074) - Account has non-zero token balance - `ErrorCode::OwnerMismatch` (error code: 6075) - Authority doesn't match owner or close_authority -- `ProgramError::InsufficientFunds` (error code: 6) - Insufficient funds for lamport transfer during rent calculation + +*Lamport distribution errors (from `distribute_lamports`):* +- `ProgramError::InsufficientFunds` (error code: 6) - Insufficient funds for compression_cost subtraction **Edge Cases and Considerations:** - Only the close_authority (if set) or owner (if close_authority is None) can use this instruction (CloseTokenAccount) diff --git a/programs/compressed-token/program/docs/ctoken/CREATE.md b/programs/compressed-token/program/docs/ctoken/CREATE.md index 4e6cccf017..2e2c16f2b6 100644 --- a/programs/compressed-token/program/docs/ctoken/CREATE.md +++ b/programs/compressed-token/program/docs/ctoken/CREATE.md @@ -73,32 +73,35 @@ - Otherwise, deserialize as `CreateTokenAccountInstructionData` 2. Parse and check accounts based on is_compressible flag - For compressible: token_account must be signer - - Validate CompressibleConfig is active (not inactive or deprecated) 3. Check mint extensions using `has_mint_extensions()` 4. If with compressible account: - 4.1. Validate rent_payment is not exactly 1 epoch (must cover more than the current rent epoch or be 0) - - Check: `compressible_config.rent_payment != 1` - - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails - - Purpose: Prevent accounts from becoming immediately compressible due to epoch boundary timing - 4.2. If with compress_to_pubkey: + 4.1. Parse payer, config, system_program, and rent_payer accounts + 4.2. Validate CompressibleConfig is active (not inactive or deprecated) + - Error: `CompressibleError::InvalidState` if not active + 4.3. If with compress_to_pubkey: - Validates: derives address from provided seeds/bump and verifies it matches token_account pubkey - Security: ensures account is a derivable PDA, preventing compression to non-signable addresses - 4.3. Validate compression_only requirement for restricted extensions: + 4.4. Validate compression_only requirement for restricted extensions: - If mint has restricted extensions (e.g., TransferFee) and compression_only == 0 - Error: `ErrorCode::CompressionOnlyRequired` - 4.3.1. Validate compression_only is only set for mints with restricted extensions: + 4.5. Validate compression_only is only set for mints with restricted extensions: - If compression_only != 0 and mint has no restricted extensions - Error: `ErrorCode::CompressionOnlyNotAllowed` - 4.4. Calculate account size based on mint extensions (includes Compressible extension) - 4.5. Calculate rent (rent exemption + prepaid epochs rent + compression incentive) - 4.6. Check whether rent_payer is custom fee payer (rent_payer != config.rent_sponsor) - 4.7. If custom rent payer: + 4.6. Validate rent_payment is not exactly 1 epoch (must cover more than the current rent epoch or be 0) + - Check: `compressible_config.rent_payment != 1` + - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails + - Purpose: Prevent accounts from becoming immediately compressible due to epoch boundary timing + 4.7. Calculate account size based on mint extensions (includes Compressible extension) + 4.8. Calculate rent (rent exemption + prepaid epochs rent + compression incentive) + 4.9. Check whether rent_payer is custom fee payer (rent_payer != config.rent_sponsor) + 4.10. If custom rent payer: - Verify rent_payer is signer (prevents executable accounts as rent_sponsor) - Create account with custom rent_payer via CPI (pays both rent exemption + additional lamports) - 4.8. If using protocol rent_sponsor: + 4.11. If using protocol rent_sponsor: - Create account with rent_sponsor PDA as fee payer via CPI (pays only rent exemption) - Transfer compression incentive to created ctoken account from payer via CPI - 4.9. `initialize_ctoken_account` (programs/compressed-token/program/src/shared/initialize_ctoken_account.rs) + 4.12. `initialize_ctoken_account` (programs/compressed-token/program/src/shared/initialize_ctoken_account.rs) + - Build extensions Vec including Compressible extension and any mint extension markers - Copy version from config (used to match config PDA version in subsequent instructions) - If custom fee payer, set custom fee payer as ctoken account rent_sponsor - Else set config.rent_sponsor as ctoken account rent_sponsor @@ -197,9 +200,6 @@ 4. Verify account is system-owned (uninitialized) - Error: `ProgramError::IllegalOwner` if not owned by system program 5. If compressible: - - Validate rent_payment is not exactly 1 epoch (same as create ctoken account step 4.1) - - Check: `compressible_config.rent_payment != 1` - - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails - Reject if compress_to_account_pubkey is Some (not allowed for ATAs) - Error: `ProgramError::InvalidInstructionData` if compress_to_account_pubkey is Some - Validate compression_only is set (required for compressible ATAs) @@ -207,6 +207,9 @@ - Error: `ErrorCode::AtaRequiresCompressionOnly` if compression_only == 0 - Parse additional accounts: config, rent_payer - Validate CompressibleConfig is active (not inactive or deprecated) + - Validate rent_payment is not exactly 1 epoch (same as create ctoken account step 4.6) + - Check: `compressible_config.rent_payment != 1` + - Error: `ErrorCode::OneEpochPrefundingNotAllowed` if validation fails - Calculate account size based on mint extensions (includes Compressible extension) - Calculate rent (rent exemption + prepaid epochs rent + compression incentive) - Check if custom rent payer (rent_payer != config.rent_sponsor) @@ -222,7 +225,7 @@ - Error: `ErrorCode::MissingCompressibleConfig` if mint has restricted extensions - Rationale: Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) must be marked as compression_only, and that marker is part of the Compressible extension 6.2. Create ATA PDA with fee_payer paying rent exemption (base 165-byte SPL layout) - 7. Initialize token account with is_ata flag set (same as ## 1. create ctoken account step 3.6, but with is_ata=true) + 7. Initialize token account with is_ata flag set (same as ## 1. create ctoken account step 4.12, but with is_ata=true) **Errors:** Same as create ctoken account with additions: diff --git a/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md index 9e4556bde0..3553e86d40 100644 --- a/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/FREEZE_ACCOUNT.md @@ -51,17 +51,17 @@ No instruction data required beyond the discriminator byte. - Verifies mint.freeze_authority == Some(freeze_authority.key()) - Verifies token_account state is Initialized (not already Frozen) - Updates token_account.state to AccountState::Frozen - - Map any errors from u64 to ProgramError::Custom(u32) + - Map SPL Token errors via `convert_pinocchio_token_error` to ErrorCode variants **Errors:** - `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 2 accounts provided (cannot get mint account) - `ProgramError::IncorrectProgramId` (error code: 7) - Mint is not owned by a valid token program (SPL Token, Token-2022, or CToken) -- SPL Token errors from pinocchio-token-program (converted from u64 to ProgramError::Custom(u32)): - - `TokenError::MintCannotFreeze` (error code: 16) - Mint's freeze_authority is None - - `TokenError::OwnerMismatch` (error code: 4) - freeze_authority doesn't match mint's freeze_authority - - `TokenError::MintMismatch` (error code: 3) - token_account's mint doesn't match provided mint - - `TokenError::InvalidState` (error code: 13) - Account is already frozen or uninitialized - - `ProgramError::InvalidAccountData` (error code: 4) - Account data is malformed +- SPL Token errors from pinocchio-token-program (converted via `convert_pinocchio_token_error` to ErrorCode variants): + - `ErrorCode::MintHasNoFreezeAuthority` (error code: 6026) - Mint's freeze_authority is None + - `ErrorCode::OwnerMismatch` (error code: 6075) - freeze_authority doesn't match mint's freeze_authority + - `ErrorCode::MintMismatch` (error code: 6155) - token_account's mint doesn't match provided mint + - `ErrorCode::InvalidState` (error code: 6163) - Account is already frozen or uninitialized + - `ErrorCode::InvalidMint` (error code: 6126) - Account data is malformed (SPL Token code 2) ## Comparison with SPL Token diff --git a/programs/compressed-token/program/docs/ctoken/MINT_TO.md b/programs/compressed-token/program/docs/ctoken/MINT_TO.md index 20f7e23363..f3757e2f83 100644 --- a/programs/compressed-token/program/docs/ctoken/MINT_TO.md +++ b/programs/compressed-token/program/docs/ctoken/MINT_TO.md @@ -68,14 +68,14 @@ Format variants: 4. **Calculate top-up requirements:** For both CMint and destination CToken accounts: - a. **Deserialize account using zero-copy:** - - CMint: Use `CompressedMint::zero_copy_at` - - CToken: Use `CToken::zero_copy_at_checked` - - Access compression info directly from embedded field (all accounts now have compression embedded) + a. **Access CompressionInfo using optimized byte access:** + - CMint: Use `cmint_top_up_lamports_from_account_info` which reads CompressionInfo at fixed byte offset (166) + - CToken: Use `top_up_lamports_from_account_info_unchecked` which reads CompressionInfo at fixed byte offset (176) + - Returns None if account lacks CompressionInfo (CMint without compression, or CToken without Compressible extension as first extension) b. **Calculate top-up amount:** - - Get current slot from Clock sysvar (lazy loaded, only if needed) - - Get rent exemption from Rent sysvar + - Get current slot from Clock sysvar (lazy loaded on first compressible account) + - Uses stored rent_exemption_paid from CompressionInfo (not Rent sysvar) - Call `calculate_top_up_lamports` which: - Checks if account is compressible - Calculates rent deficit if any @@ -83,7 +83,7 @@ Format variants: - Returns 0 if account is well-funded c. **Track lamports budget:** - - Initialize budget to max_top_up + 1 (allowing exact match) + - Initialize budget to max_top_up.saturating_add(1) (allowing exact match) - Subtract CMint top-up amount from budget - Subtract CToken top-up amount from budget - If budget reaches 0 and max_top_up is not 0, fail with MaxTopUpExceeded @@ -102,11 +102,8 @@ Format variants: - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match CMint mint_authority - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account or calculate top-up amount -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account (not 165 bytes) is missing the Compressible extension +- `CTokenError::MissingPayer` (error code: 18061) - Payer account missing when top-ups are needed --- @@ -116,7 +113,7 @@ Format variants: CToken delegates core logic to `pinocchio_token_program::processor::mint_to::process_mint_to`, which implements SPL Token-compatible mint semantics: - Authority validation, balance/supply updates, frozen check, mint matching, overflow protection -- **MintToChecked:** CToken implements CTokenMintToChecked (discriminator: 14) with full decimals validation. See `CTOKEN_MINT_TO_CHECKED.md`. +- **MintToChecked:** CToken implements CTokenMintToChecked (discriminator: 14) with full decimals validation. See `MINT_TO_CHECKED.md`. ### CToken-Specific Features diff --git a/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md b/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md index 07ef6f261e..41fc21e3ba 100644 --- a/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/MINT_TO_CHECKED.md @@ -13,7 +13,8 @@ Account layouts: - `CompressionInfo` extension defined in: program-libs/compressible/src/compression_info.rs **Instruction data:** -Path: programs/compressed-token/program/src/ctoken/mint_to.rs (lines 62-112, function `process_ctoken_mint_to_checked`) +Path: programs/compressed-token/program/src/ctoken/mint_to.rs (function `process_ctoken_mint_to_checked`) +Shared implementation: programs/compressed-token/program/src/ctoken/burn.rs (function `process_ctoken_supply_change_inner`) Byte layout: - Bytes 0-7: `amount` (u64, little-endian) - Number of tokens to mint @@ -66,28 +67,25 @@ Format variants: - Checks destination CToken is not frozen - Increases destination CToken balance by amount - Increases CMint supply by amount - - Errors are converted from pinocchio errors to ProgramError::Custom + - Errors are converted from pinocchio errors to ErrorCode variants 4. **Calculate and execute top-up transfers:** - - Calculate lamports needed for CMint based on compression state - - Calculate lamports needed for CToken based on compression state + - Calculate lamports needed for CMint based on compression state (skipped if not compressible) + - Calculate lamports needed for CToken based on compression state (skipped if no Compressible extension) - Validate total against max_top_up budget - Transfer lamports from authority to both accounts if needed **Errors:** -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 3 accounts provided -- `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data length is not 9 or 11 bytes -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::MintMismatch` (error code: 3) - CToken mint doesn't match CMint - - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match CMint mint_authority - - `TokenError::MintDecimalsMismatch` (error code: 18) - Decimals don't match CMint's decimals - - `TokenError::AccountFrozen` (error code: 17) - CToken account is frozen -- `CTokenError::CMintDeserializationFailed` (error code: 18047) - Failed to deserialize CMint account using zero-copy -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account or calculate top-up amount -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation -- `CTokenError::MaxTopUpExceeded` (error code: 18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit -- `CTokenError::MissingCompressibleExtension` (error code: 18056) - CToken account (not 165 bytes) is missing the Compressible extension +- `ProgramError::NotEnoughAccountKeys` - Less than 3 accounts provided +- `ProgramError::InvalidInstructionData` - Instruction data length is not 9 or 11 bytes +- Pinocchio token errors (converted to ErrorCode variants via `convert_pinocchio_token_error`): + - `ErrorCode::MintMismatch` (6155) - CToken mint doesn't match CMint + - `ErrorCode::OwnerMismatch` (6075) - Authority doesn't match CMint mint_authority + - `ErrorCode::MintDecimalsMismatch` (6166) - Decimals don't match CMint's decimals + - `ErrorCode::AccountFrozen` (6076) - CToken account is frozen +- `CTokenError::MaxTopUpExceeded` (18043) - Total top-up amount (CMint + CToken) exceeds max_top_up limit +- `CTokenError::MissingPayer` (18061) - Payer account not provided but top-ups are needed --- diff --git a/programs/compressed-token/program/docs/ctoken/REVOKE.md b/programs/compressed-token/program/docs/ctoken/REVOKE.md index e5fcd390ba..88e3474bbb 100644 --- a/programs/compressed-token/program/docs/ctoken/REVOKE.md +++ b/programs/compressed-token/program/docs/ctoken/REVOKE.md @@ -8,17 +8,17 @@ **Important:** This instruction is only compatible with the SPL Token instruction format (using `spl_token_2022::instruction::revoke` with changed program ID) when **no top-up is required**. -If the CToken account has a compressible extension and requires a rent top-up, the instruction needs the **system program account** to perform the lamports transfer. Without the system program account, the top-up CPI will fail. +If the CToken account has a compressible extension and requires a rent top-up, the instruction needs the **payer account** to transfer lamports. Without the payer account, the top-up CPI will fail. **Compatibility scenarios:** -- **SPL-compatible (no system program needed):** Non-compressible accounts, or compressible accounts with sufficient prepaid rent -- **NOT SPL-compatible (system program required):** Compressible accounts that need rent top-up based on current slot +- **SPL-compatible (no payer needed):** Non-compressible accounts, or compressible accounts with sufficient prepaid rent +- **NOT SPL-compatible (payer required):** Compressible accounts that need rent top-up based on current slot **description:** -Revokes any previously granted delegation on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). Before the revoke operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible revoke semantics. Supports backwards-compatible instruction data format (0 bytes legacy vs 2 bytes with max_top_up). The revoke operation follows SPL Token rules exactly (clears delegate and delegated_amount). +Revokes any previously granted delegation on a decompressed ctoken account (account layout `CToken` defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs). After the revoke operation, automatically tops up compressible accounts (extension layout `CompressionInfo` defined in program-libs/compressible/src/compression_info.rs) with additional lamports if needed to prevent accounts from becoming compressible during normal operations. The instruction supports a max_top_up parameter (0 = no limit) that enforces transaction failure if the calculated top-up exceeds this limit. Uses pinocchio-token-program for SPL-compatible revoke semantics. Supports backwards-compatible instruction data format (0 bytes legacy vs 2 bytes with max_top_up). The revoke operation follows SPL Token rules exactly (clears delegate and delegated_amount). **Instruction data:** -Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 71-106) +Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 49-59 for revoke, lines 86-124 for top-up processing) - Empty (0 bytes): legacy format, no max_top_up enforcement (max_top_up = 0, no limit) - Bytes 0-1 (optional): `max_top_up` (u16, little-endian) - Maximum lamports for top-up (0 = no limit) @@ -37,43 +37,37 @@ Path: programs/compressed-token/program/src/ctoken/approve_revoke.rs (lines 71-1 **Instruction Logic and Checks:** -1. **Parse instruction data:** - - If 0 bytes: legacy format, set max_top_up = 0 (no limit) - - If 2 bytes: parse max_top_up (u16, little-endian) - - Return InvalidInstructionData for any other length - -2. **Validate minimum accounts:** - - Require at least 2 accounts (source, owner) +1. **Validate minimum accounts:** + - Require at least 1 account (pinocchio's process_revoke requires at least 2: source, owner) - Return NotEnoughAccountKeys if insufficient -3. **Process compressible top-up:** - - Borrow source account data mutably - - Deserialize CToken using zero-copy validation - - Initialize lamports_budget based on max_top_up: - - If max_top_up == 0: budget = u64::MAX (no limit) - - Otherwise: budget = max_top_up + 1 (allows exact match) - - Call process_compression_top_up with source account's compression info - - Drop borrow before CPI - - If transfer_amount > 0: - - Check that transfer_amount <= lamports_budget - - Return MaxTopUpExceeded if budget exceeded - - Transfer lamports from owner to source via CPI - -4. **Process revoke (inline via pinocchio-token-program library):** +2. **Process revoke (inline via pinocchio-token-program library):** - Call process_revoke with accounts - Clears the delegate field and delegated_amount on the source account + - Validates owner authority and account state + +3. **Handle compressible top-up (if applicable):** + - Fast path: if account data length is 165 bytes (no extensions), skip top-up + - Otherwise, process compressible top-up: + - Parse instruction data to get max_top_up: + - If 0 bytes: legacy format, set max_top_up = 0 (no limit) + - If 2 bytes: parse max_top_up (u16, little-endian) + - Return InvalidInstructionData for any other length + - Calculate required top-up using `top_up_lamports_from_account_info_unchecked` + - If transfer_amount > 0: + - If max_top_up > 0 and transfer_amount > max_top_up: return MaxTopUpExceeded + - Get payer from accounts[1], return MissingPayer if not present + - Transfer lamports from payer to source via CPI **Errors:** +- `ProgramError::NotEnoughAccountKeys` (error code: 11) - No accounts provided - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 0 or 2 bytes -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 2 accounts provided -- `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up parameter -- `ProgramError::MissingRequiredSignature` (error code: 8) - Owner did not sign the transaction (SPL Token error) -- Pinocchio token errors (converted to ProgramError::Custom): - - `TokenError::OwnerMismatch` (error code: 4) - Authority doesn't match account owner - - `TokenError::AccountFrozen` (error code: 17) - Account is frozen +- `CTokenError::MissingPayer` (error code: 18061) - Top-up required but payer account not provided +- Pinocchio token errors (mapped to ErrorCode via convert_pinocchio_token_error): + - `TokenError::OwnerMismatch` (error code: 6075) - Authority doesn't match account owner + - `TokenError::AccountFrozen` (error code: 6076) - Account is frozen ## Comparison with SPL Token @@ -85,7 +79,7 @@ CToken delegates core logic to `pinocchio_token_program::processor::revoke::proc ### CToken-Specific Features **1. Compressible Top-Up Logic** -Automatically tops up accounts with rent lamports before revoking to prevent accounts from becoming compressible. +Automatically tops up accounts with rent lamports after revoking to prevent accounts from becoming compressible. **2. max_top_up Parameter** 2-byte instruction format adds `max_top_up` (u16) to limit top-up costs. Fails with `MaxTopUpExceeded` (18043) if exceeded. diff --git a/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md b/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md index 0c35639fe4..5017e1a1fc 100644 --- a/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md +++ b/programs/compressed-token/program/docs/ctoken/THAW_ACCOUNT.md @@ -15,25 +15,30 @@ No instruction data required beyond the discriminator byte. - (mutable) - The frozen ctoken account to thaw - Must be frozen (AccountState::Frozen) + - Must not be a native token account - Will have state field updated to AccountState::Initialized 2. mint - The mint account associated with the token account - Must be owned by SPL Token, Token-2022, or CToken program - Must have freeze_authority set (not None) + - Must match token_account.mint 3. freeze_authority - - (signer) + - (signer, or multisig with signers in remaining accounts) - Must match the mint's freeze_authority - - Must sign the transaction + - Must sign the transaction (or provide sufficient multisig signers) + +4. remaining accounts (optional) + - Additional signer accounts if freeze_authority is a multisig **Instruction Logic and Checks:** -1. **Validate minimum accounts:** - - Require at least 2 accounts to get mint account (index 1) +1. **Validate minimum accounts (CToken layer):** + - Require at least 2 accounts to access mint account (index 1) - Return NotEnoughAccountKeys if insufficient -2. **Validate mint ownership:** +2. **Validate mint ownership (CToken layer):** - Get mint account (accounts[1]) - Call `check_token_program_owner(mint_info)` from programs/compressed-token/program/src/shared/owner_validation.rs - Verify mint is owned by one of: @@ -42,40 +47,51 @@ No instruction data required beyond the discriminator byte. - CToken program (cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m) - Return IncorrectProgramId if mint owner doesn't match -3. **Process thaw (inline via pinocchio-token-program library):** +3. **Process thaw (pinocchio-token-program layer):** - Call `process_thaw_account(accounts)` from pinocchio-token-program - - This performs standard SPL Token thaw validation: - - Verifies token_account is mutable - - Verifies freeze_authority is signer - - Verifies token_account.mint == mint.key() - - Verifies mint.freeze_authority == Some(freeze_authority.key()) - - Verifies token_account state is Frozen (not already Initialized) + - Internally calls `process_toggle_account_state(accounts, false)` which: + - Requires at least 3 accounts: [source_account, mint, authority, remaining...] + - Loads token account mutably and validates it is initialized + - Verifies token_account state is Frozen (returns InvalidState if already Initialized) + - Verifies token_account is not a native token (returns NativeNotSupported) + - Verifies token_account.mint == mint.key() (returns MintMismatch) + - Loads mint and verifies freeze_authority is set (returns MintCannotFreeze if None) + - Validates owner via `validate_owner()`: + - Checks freeze_authority key matches expected authority + - If authority is a multisig account, validates sufficient signers from remaining accounts + - If authority is a regular account, verifies it is a signer - Updates token_account.state to AccountState::Initialized - - Map any errors from u64 to ProgramError::Custom(u32) + - Errors are converted via `convert_pinocchio_token_error` to anchor ErrorCode variants **Errors:** -- `ProgramError::NotEnoughAccountKeys` (error code: 11) - Less than 2 accounts provided (cannot get mint account) -- `ProgramError::IncorrectProgramId` (error code: 7) - Mint is not owned by a valid token program (SPL Token, Token-2022, or CToken) -- SPL Token errors from pinocchio-token-program (converted from u64 to ProgramError::Custom(u32)): - - `TokenError::MintCannotFreeze` (error code: 16) - Mint's freeze_authority is None - - `TokenError::OwnerMismatch` (error code: 4) - freeze_authority doesn't match mint's freeze_authority - - `TokenError::MintMismatch` (error code: 3) - token_account's mint doesn't match provided mint - - `TokenError::InvalidState` (error code: 13) - Account is not frozen or is uninitialized - - `ProgramError::InvalidAccountData` (error code: 4) - Account data is malformed +- `ProgramError::NotEnoughAccountKeys` - Less than 2 accounts provided (CToken check), or less than 3 accounts for pinocchio processor +- `ProgramError::IncorrectProgramId` - Mint is not owned by a valid token program (SPL Token, Token-2022, or CToken) +- SPL Token errors from pinocchio-token-program (converted to anchor ErrorCode variants): + - `ErrorCode::MintHasNoFreezeAuthority` (SPL code 16) - Mint's freeze_authority is None + - `ErrorCode::OwnerMismatch` (SPL code 4) - freeze_authority doesn't match mint's freeze_authority + - `ErrorCode::MintMismatch` (SPL code 3) - token_account's mint doesn't match provided mint + - `ErrorCode::InvalidState` (SPL code 13) - Account is not frozen (already Initialized or uninitialized) + - `ErrorCode::NativeNotSupported` (SPL code 10) - Cannot thaw native token accounts + - `ProgramError::MissingRequiredSignature` - Authority is not a signer or multisig threshold not met ## Comparison with SPL Token ### Functional Parity CToken delegates core logic to `pinocchio_token_program::processor::thaw_account::process_thaw_account`, which implements SPL Token-compatible thaw semantics: -- State transition (Frozen → Initialized), freeze authority validation, mint association check +- State transition (Frozen -> Initialized), freeze authority validation, mint association check ### CToken-Specific Features **1. Explicit Mint Ownership Validation** -CToken adds `check_token_program_owner(mint)` before delegating to thaw logic, validating mint is owned by SPL Token, Token-2022, or CToken program. +CToken adds `check_token_program_owner(mint)` before delegating to thaw logic, validating mint is owned by SPL Token, Token-2022, or CToken program. This allows CToken mints to be thawed as well as standard SPL/Token-2022 mints. + +### Supported SPL Features + +**1. Multisig Support** +The pinocchio-token-program implementation supports multisig freeze authorities. If the freeze_authority is a multisig account, additional signer accounts can be passed in the remaining accounts to meet the signature threshold. -### Unsupported SPL & Token-2022 Features +### Unsupported Token-2022 Features -**1. No Multisig Support** -**2. No CPI Guard Extension Check** +**1. No CPI Guard Extension Check** +Token-2022's CPI guard extension check is not performed. diff --git a/programs/compressed-token/program/docs/ctoken/TRANSFER.md b/programs/compressed-token/program/docs/ctoken/TRANSFER.md index 17e787aa71..be7e484bcd 100644 --- a/programs/compressed-token/program/docs/ctoken/TRANSFER.md +++ b/programs/compressed-token/program/docs/ctoken/TRANSFER.md @@ -25,7 +25,7 @@ When accounts require rent top-up, lamports are transferred directly from the au - Top-up prevents accounts from becoming compressible during normal operations 6. Supports standard SPL Token transfer features including delegate authority and permanent delegate (multisig not supported) 7. The transfer amount and authority validation follow SPL Token rules exactly -8. Validates T22 extension markers match between source and destination (pausable, permanent_delegate, transfer_fee, transfer_hook) +8. Validates T22 extension markers match between source and destination (pausable, permanent_delegate, transfer_fee, transfer_hook, default_account_state) **Instruction data:** After discriminator byte, the following formats are supported: @@ -60,24 +60,32 @@ Note: The authority account (index 2) also serves as the payer for top-ups when - Require at least 3 accounts (source, destination, authority) - Return NotEnoughAccountKeys if insufficient -2. **Validate instruction data:** +2. **Validate minimum instruction data:** - Must be at least 8 bytes (amount) + - Return InvalidInstructionData if less than 8 bytes + +3. **Hot path optimization (no extensions):** + - If both source and destination accounts are exactly 165 bytes (standard SPL token account size without extensions): + - Skip all extension processing and max_top_up validation + - Pass only the first 8 bytes (amount) directly to pinocchio SPL transfer + - This is the fast path for accounts without compressible or T22 extensions + +4. **Parse max_top_up (extended path only):** - If 10 bytes, parse max_top_up from bytes [8..10] - If 8 bytes, set max_top_up = 0 (legacy, no limit) - Any other length returns InvalidInstructionData -3. **Process transfer extensions:** +5. **Process transfer extensions:** - Call `process_transfer_extensions` from shared.rs with source, destination, authority (no mint) a. **Validate sender (source account):** - Deserialize source account (CToken) using zero-copy - Check for T22 restricted extensions (pausable, permanent_delegate, transfer_fee, transfer_hook, default_account_state) - - If source has restricted extensions, deserialize and validate mint extensions: - - Mint must not be paused - - Transfer fees must be zero - - Transfer hooks must have nil program_id - - Extract permanent delegate if present - - Validate permanent delegate authority if applicable + - If source has restricted extension markers: + - Require mint account (MintRequiredForTransfer error if not provided) + - Fail with MintHasRestrictedExtensions if mint has any restricted extensions + - Note: CTokenTransfer does NOT support restricted extensions; use CTokenTransferChecked instead + - Validate permanent delegate authority if applicable (from mint extension) - Calculate top-up lamports from compression info b. **Validate recipient (destination account):** @@ -93,8 +101,9 @@ Note: The authority account (index 2) also serves as the payer for top-ups when - Check max_top_up budget if set (non-zero) - Execute multi_transfer_lamports from authority to accounts -4. **Process SPL transfer:** +6. **Process SPL transfer:** - Call pinocchio_token_program::processor::transfer::process_transfer + - Pass only the first 8 bytes (amount) to the processor - Pass signer_is_validated flag if permanent delegate was validated **Errors:** @@ -109,9 +118,7 @@ Note: The authority account (index 2) also serves as the payer for top-ups when - `TokenError::AccountFrozen` (error code: 17) - Source or destination account is frozen - `TokenError::InsufficientFunds` (error code: 1) - Delegate has insufficient allowance - `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account, mint mismatch, or invalid extension data -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up limit -- `ErrorCode::MintRequiredForTransfer` (error code: 6128) - Account has restricted extensions but mint account not provided -- `ErrorCode::MintPaused` (error code: 6127) - Mint has pausable extension and is currently paused -- `ErrorCode::NonZeroTransferFeeNotSupported` (error code: 6129) - Mint has non-zero transfer fee configured -- `ErrorCode::TransferHookNotSupported` (error code: 6130) - Mint has transfer hook with non-nil program_id +- `ErrorCode::MintRequiredForTransfer` (error code: 6128) - Source account has restricted extension markers but mint account not provided +- `ErrorCode::MintHasRestrictedExtensions` (error code: 6142) - Mint has restricted extensions; CTokenTransfer does not support restricted extensions (use CTokenTransferChecked instead) diff --git a/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md b/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md index 73046bd193..6c360b3e1b 100644 --- a/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md +++ b/programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md @@ -14,6 +14,9 @@ When accounts require rent top-up, lamports are transferred directly from the au - **SPL-compatible:** When using 9-byte instruction data (amount + decimals) with no top-up needed - **Extended format:** When using 11-byte instruction data (amount + decimals + max_top_up) for compressible accounts +**Hot path optimization:** +When both source and destination accounts are exactly 165 bytes (no extensions), the instruction bypasses all extension processing and directly calls pinocchio process_transfer_checked for maximum performance. + **description:** Transfers tokens between decompressed ctoken solana accounts with mint decimals validation, fully compatible with SPL Token TransferChecked semantics. Account layout `CToken` is defined in program-libs/ctoken-interface/src/state/ctoken/ctoken_struct.rs. Compression info for rent top-up is defined in program-libs/compressible/src/compression_info.rs. Uses pinocchio-token-program to process the transfer (lightweight SPL-compatible implementation). After the transfer, automatically tops up compressible accounts with additional lamports if needed based on current slot and account balance. Top-up prevents accounts from becoming compressible during normal operations. Supports standard SPL Token transfer features including delegate authority and permanent delegate (multisig not supported). The transfer amount, authority validation, and decimals validation follow SPL Token TransferChecked rules exactly. Validates that mint decimals match the provided decimals parameter. Difference from CTokenTransfer: Requires mint account (4 accounts vs 3) for decimals validation and T22 extension validation. @@ -55,22 +58,28 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals **Instruction Logic and Checks:** 1. **Validate minimum accounts:** - - Require exactly 4 accounts (source, mint, destination, authority) + - Require at least 4 accounts (source, mint, destination, authority) - Return NotEnoughAccountKeys if insufficient -2. **Validate instruction data:** +2. **Hot path for accounts without extensions:** + - If both source and destination are exactly 165 bytes (no extensions): + - Directly call pinocchio process_transfer_checked with first 9 bytes of instruction data + - Skip all extension processing for maximum performance + - Return immediately + +3. **Validate instruction data:** - Must be at least 9 bytes (amount + decimals) - If 11 bytes, parse max_top_up from bytes [9..11] - If 9 bytes, set max_top_up = 0 (legacy, no limit) - Any other length returns InvalidInstructionData -3. **Parse max_top_up parameter:** +4. **Parse max_top_up parameter:** - 0 = no limit on top-up lamports - Non-zero = maximum combined lamports for source + destination top-up - Transaction fails if calculated top-up exceeds max_top_up -4. **Process transfer extensions:** - - Call process_transfer_extensions from shared.rs with source, destination, authority, mint, and max_top_up +5. **Process transfer extensions:** + - Call process_transfer_extensions_transfer_checked from shared.rs with source, destination, authority, mint, and max_top_up - Validate sender (source account): - Deserialize source account (CToken) and extract extension information - Validate mint account matches source token's mint field @@ -89,19 +98,18 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals - Verify sender and destination have matching T22 extension markers - Calculate top-up amounts for both accounts based on compression info: - Get current slot from Clock sysvar (lazy loaded once) - - Get rent exemption from Rent sysvar - Call calculate_top_up_lamports for each account - Transfer lamports from authority to accounts if top-up needed: - Check max_top_up budget if set (non-zero) - Execute multi_transfer_lamports atomically - - Return (signer_is_validated, decimals) tuple + - Return (signer_is_validated, extension_decimals) tuple -5. **Extract decimals and execute transfer:** +6. **Extract decimals and execute transfer:** - Parse amount and decimals from instruction data using unpack_amount_and_decimals - If source account has cached decimals in compressible extension (extension_decimals is Some): - Validate extension_decimals == instruction decimals parameter - Create accounts slice without mint: [source, destination, authority] - - Call pinocchio process_transfer with expected_decimals = None + - Call pinocchio process_transfer with expected_decimals = None (3 accounts) - signer_is_validated flag from permanent delegate check skips redundant owner/delegate validation - If no cached decimals (extension_decimals is None): - Validate mint account owner is token program @@ -122,7 +130,7 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals - `ProgramError::InvalidInstructionData` (error code: 3) - Instruction data is not 9 or 11 bytes, or decimals validation failed - `ProgramError::MissingRequiredSignature` (error code: 8) - Authority is permanent delegate but not a signer - `CTokenError::InvalidAccountData` (error code: 18002) - Failed to deserialize CToken account, mint mismatch, or invalid extension data -- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock or Rent sysvar for top-up calculation +- `CTokenError::SysvarAccessError` (error code: 18020) - Failed to get Clock sysvar for top-up calculation - `CTokenError::MaxTopUpExceeded` (error code: 18043) - Calculated top-up exceeds max_top_up limit - `ProgramError::InsufficientFunds` (error code: 6) - Source balance less than amount (pinocchio error) - Pinocchio token errors (converted to ProgramError::Custom): @@ -140,8 +148,10 @@ Transfers tokens between decompressed ctoken solana accounts with mint decimals ### Functional Parity -CToken delegates core logic to `pinocchio_token_program::processor::transfer_checked::process_transfer_checked`, which implements SPL Token-compatible transfer semantics: -- Authority validation, balance updates, frozen check, mint matching, decimals validation +CToken delegates core logic to `pinocchio_token_program::processor::shared::transfer::process_transfer`, which implements SPL Token-compatible transfer semantics. When `expected_decimals` is Some, it performs decimals validation against the mint account: +- Authority validation, balance updates, frozen check, mint matching, decimals validation (when expected_decimals is Some) + +Note: For the hot path (165-byte accounts without extensions), `pinocchio_token_program::processor::transfer_checked::process_transfer_checked` is called directly. ### CToken-Specific Features diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index dc545ba397..e1179e41fe 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -12,7 +12,7 @@ use pinocchio::pubkey::pubkey_eq; use spl_pod::solana_msg::msg; use super::{ - compress_and_close::process_compress_and_close, decompress::apply_decompress_extension_state, + compress_and_close::process_compress_and_close, decompress::validate_and_apply_compressed_only, inputs::CTokenCompressionInputs, }; use crate::shared::{ @@ -81,7 +81,7 @@ pub fn compress_or_decompress_ctokens( Ok(()) } ZCompressionMode::Decompress => { - apply_decompress_extension_state( + validate_and_apply_compressed_only( token_account_info, &mut ctoken, decompress_inputs, diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs index c9c518392b..878c323883 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -11,9 +11,9 @@ use spl_pod::solana_msg::msg; use super::inputs::DecompressCompressOnlyInputs; -/// Apply extension state from compressed account to CToken during decompress. +/// Validate and apply CompressedOnly extension state from compressed account to CToken during decompress. #[inline(always)] -pub fn apply_decompress_extension_state( +pub fn validate_and_apply_compressed_only( destination_account: &AccountInfo, ctoken: &mut ZCTokenMut, decompress_inputs: Option, diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index 96ae4bbbd3..3b2607d485 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -12,6 +12,7 @@ use light_ctoken_interface::{ state::{ CompressedOnlyExtension, CompressedTokenAccountState, ExtensionStruct, TokenDataVersion, }, + CTokenError, }; use pinocchio::account_info::AccountInfo; @@ -80,41 +81,7 @@ pub fn set_input_compressed_account<'a>( // of the compressed account owner (which is the ATA pubkey that can't sign). // Also verify that owner_account (the ATA) matches the derived ATA from wallet_owner + mint + bump. let signer_account = if let Some(exts) = tlv_data { - exts.iter() - .find_map(|ext| { - if let ZExtensionInstructionData::CompressedOnly(data) = ext { - if data.is_ata != 0 { - // Get wallet owner from owner_index - let wallet_owner = packed_accounts.get(data.owner_index as usize)?; - - // Derive ATA and verify owner_account matches - let bump_seed = [data.bump]; - let ata_seeds: [&[u8]; 4] = [ - wallet_owner.key().as_ref(), - crate::LIGHT_CPI_SIGNER.program_id.as_ref(), - mint_account.key().as_ref(), - bump_seed.as_ref(), - ]; - let derived_ata = pinocchio::pubkey::create_program_address( - &ata_seeds, - &crate::LIGHT_CPI_SIGNER.program_id, - ) - .ok()?; - - // owner_account.key() IS the ATA - verify it matches derived - if !pinocchio::pubkey::pubkey_eq(owner_account.key(), &derived_ata) { - return None; // Will cause error below - } - - Some(wallet_owner) - } else { - None - } - } else { - None - } - }) - .unwrap_or(owner_account) + resolve_ata_signer(exts, packed_accounts, mint_account, owner_account)? } else { owner_account }; @@ -136,30 +103,10 @@ pub fn set_input_compressed_account<'a>( CompressedTokenAccountState::Initialized as u8 }; // Convert instruction TLV data to state TLV - let tlv: Option> = match tlv_data { - Some(exts) => { - let mut result = Vec::with_capacity(exts.len()); - for ext in exts.iter() { - match ext { - ZExtensionInstructionData::CompressedOnly(data) => { - result.push(ExtensionStruct::CompressedOnly( - CompressedOnlyExtension { - delegated_amount: data.delegated_amount.into(), - withheld_transfer_fee: data - .withheld_transfer_fee - .into(), - is_ata: if data.is_ata() { 1 } else { 0 }, - }, - )); - } - _ => { - return Err(ErrorCode::UnsupportedTlvExtensionType.into()); - } - } - } - Some(result) - } - None => None, + let tlv: Option> = if let Some(exts) = tlv_data { + Some(convert_tlv_to_extension_structs(exts)?) + } else { + None }; let token_data = TokenData { mint: mint_account.key().into(), @@ -211,6 +158,85 @@ pub fn set_input_compressed_account<'a>( Ok(()) } +/// Convert instruction TLV data to state TLV extension structs for hashing. +#[cold] +fn convert_tlv_to_extension_structs( + exts: &[ZExtensionInstructionData], +) -> Result, ProgramError> { + let mut result = Vec::with_capacity(exts.len()); + for ext in exts.iter() { + match ext { + ZExtensionInstructionData::CompressedOnly(data) => { + result.push(ExtensionStruct::CompressedOnly(CompressedOnlyExtension { + delegated_amount: data.delegated_amount.into(), + withheld_transfer_fee: data.withheld_transfer_fee.into(), + is_ata: if data.is_ata() { 1 } else { 0 }, + })); + } + _ => { + return Err(ErrorCode::UnsupportedTlvExtensionType.into()); + } + } + } + Ok(result) +} + +/// Resolve the signer account for ATA decompress operations. +/// +/// For non-ATA tokens: returns owner_account (the compressed token owner) +/// For ATA tokens (is_ata=true): validates ATA derivation and returns wallet_owner +/// +/// Returns explicit error if ATA derivation fails or mismatches. +#[cold] +fn resolve_ata_signer<'a>( + exts: &[ZExtensionInstructionData], + packed_accounts: &'a [AccountInfo], + mint_account: &AccountInfo, + owner_account: &'a AccountInfo, +) -> Result<&'a AccountInfo, ProgramError> { + for ext in exts.iter() { + if let ZExtensionInstructionData::CompressedOnly(data) = ext { + if data.is_ata() { + // Get wallet owner from owner_index + let wallet_owner = + packed_accounts + .get(data.owner_index as usize) + .ok_or_else(|| { + print_on_error_pubkey( + data.owner_index, + "wallet_owner", + Location::caller(), + ); + ProgramError::Custom(AccountError::NotEnoughAccountKeys.into()) + })?; + + // Derive ATA and verify owner_account matches + let bump_seed = [data.bump]; + let ata_seeds: [&[u8]; 4] = [ + wallet_owner.key().as_ref(), + crate::LIGHT_CPI_SIGNER.program_id.as_ref(), + mint_account.key().as_ref(), + bump_seed.as_ref(), + ]; + let derived_ata = pinocchio::pubkey::create_program_address( + &ata_seeds, + &crate::LIGHT_CPI_SIGNER.program_id, + ) + .map_err(|_| CTokenError::InvalidAtaDerivation)?; + + // owner_account.key() IS the ATA - verify it matches derived + if !pinocchio::pubkey::pubkey_eq(owner_account.key(), &derived_ata) { + return Err(CTokenError::InvalidAtaDerivation.into()); + } + + return Ok(wallet_owner); + } + } + } + + Ok(owner_account) +} + #[cold] fn print_on_error_pubkey(index: u8, account_name: &str, location: &Location) { anchor_lang::prelude::msg!( From 9d9e30d8e90868d5838ac6dbef4b4e5680f1d422 Mon Sep 17 00:00:00 2001 From: ananas Date: Sun, 11 Jan 2026 19:42:03 +0000 Subject: [PATCH 35/38] agent review fixes --- .../compressed-token/anchor/src/freeze.rs | 4 +- .../anchor/src/process_transfer.rs | 45 +- .../program/docs/ACCOUNT_CHECKS.md | 1154 +++++++++++++++++ .../program/docs/COMPRESSIBLE_ATA.md | 458 +++++++ .../compression/ctoken/compress_and_close.rs | 6 +- .../compression/ctoken/decompress.rs | 6 +- .../transfer2/compression/mod.rs | 11 +- .../transfer2/compression/spl.rs | 6 +- 8 files changed, 1675 insertions(+), 15 deletions(-) create mode 100644 programs/compressed-token/program/docs/ACCOUNT_CHECKS.md create mode 100644 programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md diff --git a/programs/compressed-token/anchor/src/freeze.rs b/programs/compressed-token/anchor/src/freeze.rs index f7f12f9fc4..17a273d07e 100644 --- a/programs/compressed-token/anchor/src/freeze.rs +++ b/programs/compressed-token/anchor/src/freeze.rs @@ -13,7 +13,7 @@ use crate::{ process_transfer::{ add_data_hash_to_input_compressed_accounts_with_version, cpi_execute_compressed_transaction_transfer, - get_input_compressed_accounts_with_merkle_context_and_check_signer, + get_input_compressed_accounts_with_merkle_context_and_check_signer_for_freeze, InputTokenDataWithContext, }, FreezeInstruction, TokenData, @@ -122,7 +122,7 @@ pub fn create_input_and_output_accounts_freeze_or_thaw< } let (mut compressed_input_accounts, input_token_data, _) = - get_input_compressed_accounts_with_merkle_context_and_check_signer::( + get_input_compressed_accounts_with_merkle_context_and_check_signer_for_freeze::( // The signer in this case is the freeze authority. The owner is not // required to sign for this instruction. Hence, we pass the owner // from a variable instead of an account to still reproduce value diff --git a/programs/compressed-token/anchor/src/process_transfer.rs b/programs/compressed-token/anchor/src/process_transfer.rs index a86ecd9bab..0694ce4417 100644 --- a/programs/compressed-token/anchor/src/process_transfer.rs +++ b/programs/compressed-token/anchor/src/process_transfer.rs @@ -599,7 +599,7 @@ pub struct CompressedTokenInstructionDataTransfer { pub with_transaction_hash: bool, } -pub fn get_input_compressed_accounts_with_merkle_context_and_check_signer( +fn get_input_compressed_accounts_with_merkle_context_and_check_signer_inner( signer: &Pubkey, signer_is_delegate: &Option, remaining_accounts: &[AccountInfo<'_>], @@ -656,10 +656,9 @@ pub fn get_input_compressed_accounts_with_merkle_context_and_check_signer( + signer: &Pubkey, + signer_is_delegate: &Option, + remaining_accounts: &[AccountInfo<'_>], + input_token_data_with_context: &[InputTokenDataWithContext], + mint: &Pubkey, +) -> Result<(Vec, Vec, u64)> { + get_input_compressed_accounts_with_merkle_context_and_check_signer_inner::( + signer, + signer_is_delegate, + remaining_accounts, + input_token_data_with_context, + mint, + ) +} + +/// Get input compressed accounts - for all other instructions (checks TLV) +pub fn get_input_compressed_accounts_with_merkle_context_and_check_signer( + signer: &Pubkey, + signer_is_delegate: &Option, + remaining_accounts: &[AccountInfo<'_>], + input_token_data_with_context: &[InputTokenDataWithContext], + mint: &Pubkey, +) -> Result<(Vec, Vec, u64)> { + get_input_compressed_accounts_with_merkle_context_and_check_signer_inner::( + signer, + signer_is_delegate, + remaining_accounts, + input_token_data_with_context, + mint, + ) +} + #[derive(Clone, Debug, PartialEq, Eq, AnchorSerialize, AnchorDeserialize)] pub struct PackedTokenTransferOutputData { pub owner: Pubkey, diff --git a/programs/compressed-token/program/docs/ACCOUNT_CHECKS.md b/programs/compressed-token/program/docs/ACCOUNT_CHECKS.md new file mode 100644 index 0000000000..5f20099a7c --- /dev/null +++ b/programs/compressed-token/program/docs/ACCOUNT_CHECKS.md @@ -0,0 +1,1154 @@ +# Account Checks Security Documentation + +This document provides comprehensive documentation of all Solana account validations in the compressed-token program. For each instruction, it details every account accessed, how it is validated, and what security checks are applied. + +## Table of Contents + +1. [Overview](#1-overview) +2. [Validation Framework Reference](#2-validation-framework-reference) +3. [Instructions by Category](#3-instructions-by-category) + - [3.1 Account Management](#31-account-management) + - [3.2 CToken Operations](#32-ctoken-operations) + - [3.3 Compressed Token Operations](#33-compressed-token-operations) + - [3.4 Compressible Rent Management](#34-compressible-rent-management) + - [3.5 Token Pool Operations (Anchor)](#35-token-pool-operations-anchor) +4. [Potential Gaps and Recommendations](#4-potential-gaps-and-recommendations) +5. [Quick Reference Tables](#5-quick-reference-tables) + +--- + +## 1. Overview + +### 1.1 Purpose and Scope + +This document covers account validation for **29 instructions** in the compressed-token program: +- **18 Pinocchio-based instructions** - Native Solana instructions with manual account parsing +- **11 Anchor-based instructions** - Instructions using Anchor framework constraints + +### 1.2 Validation Mechanisms Used + +| Mechanism | Description | Location | +|-----------|-------------|----------| +| `AccountIterator` | Sequential account parsing with named error locations | `program-libs/account-checks/src/account_iterator.rs` | +| `ProgramPackedAccounts` | Index-based dynamic account access | `program-libs/account-checks/src/packed_accounts.rs` | +| `check_signer()` | Verify account is transaction signer | `program-libs/account-checks/src/checks.rs:121` | +| `check_mut()` | Verify account is writable | `program-libs/account-checks/src/checks.rs:128` | +| `check_non_mut()` | Verify account is read-only | `program-libs/account-checks/src/checks.rs:43` | +| `check_owner()` | Verify account program ownership | `program-libs/account-checks/src/checks.rs:135` | +| `check_discriminator()` | Verify 8-byte account type prefix | `program-libs/account-checks/src/checks.rs:78` | +| `check_pda_seeds()` | Verify PDA derivation with find_program_address | `program-libs/account-checks/src/checks.rs:158` | +| `check_pda_seeds_with_bump()` | Verify PDA derivation with known bump | `program-libs/account-checks/src/checks.rs:170` | +| `verify_owner_or_delegate_signer()` | Token authority validation (owner/delegate/permanent_delegate) | `src/shared/owner_validation.rs:30` | +| `check_ctoken_owner()` | Compression authority validation | `src/shared/owner_validation.rs:83` | + +### 1.3 Error Code Ranges + +| Range | Source | Description | +|-------|--------|-------------| +| 20000-20015 | `AccountError` | Account validation errors from account-checks | +| 18001-18066 | `CTokenError` | Compressed token specific errors | +| 6000+ | `ErrorCode` | Anchor compressed token errors | + +**AccountError Codes (20000-20015):** + +| Code | Error | Description | +|------|-------|-------------| +| 20000 | `InvalidDiscriminator` | Account type prefix mismatch | +| 20001 | `AccountOwnedByWrongProgram` | Owner check failed | +| 20002 | `AccountNotMutable` | Mutability check failed | +| 20003 | `BorrowAccountDataFailed` | Cannot borrow account data | +| 20004 | `InvalidAccountSize` | Account size mismatch | +| 20005 | `AccountMutable` | Non-mutability check failed | +| 20006 | `AlreadyInitialized` | Account discriminator not zeroed | +| 20007 | `InvalidAccountBalance` | Insufficient lamports for rent | +| 20008 | `FailedBorrowRentSysvar` | Cannot read rent sysvar | +| 20009 | `InvalidSigner` | Signer check failed | +| 20010 | `InvalidSeeds` | PDA derivation mismatch | +| 20011 | `InvalidProgramId` | Program ID check failed | +| 20012 | `ProgramNotExecutable` | Program not executable | +| 20013 | `AccountNotZeroed` | Account not zeroed for init | +| 20014 | `NotEnoughAccountKeys` | Insufficient accounts provided | +| 20015 | `InvalidAccount` | Generic account validation failure | + +--- + +## 2. Validation Framework Reference + +### 2.1 AccountIterator Pattern + +Sequential account parsing with automatic validation and error location tracking. + +```rust +let mut iter = AccountIterator::new(account_infos); +let fee_payer = iter.next_signer_mut("fee_payer")?; // Checks: signer + mutable +let mint = iter.next_non_mut("mint")?; // Checks: non-mutable +let authority = iter.next_signer("authority")?; // Checks: signer only +let optional = iter.next_option_mut("opt", condition)?; // Conditional mutable +``` + +**Methods and Their Checks:** + +| Method | Signer | Mutable | Non-Mutable | +|--------|--------|---------|-------------| +| `next_account()` | - | - | - | +| `next_signer()` | Y | - | - | +| `next_mut()` | - | Y | - | +| `next_non_mut()` | - | - | Y | +| `next_signer_mut()` | Y | Y | - | +| `next_signer_non_mut()` | Y | - | Y | +| `next_option()` | - | - | - | +| `next_option_mut()` | - | Y | - | +| `next_option_signer()` | Y | - | - | + +### 2.2 Authority Validation Functions + +**`verify_owner_or_delegate_signer()`** - Token operations authorization: +``` +Location: src/shared/owner_validation.rs:30-78 +Accepts: +1. Owner account is signer -> OK +2. Delegate account is signer -> OK +3. Permanent delegate (from mint extension) is signer -> OK +Error: ErrorCode::OwnerMismatch (if none are signers) +``` + +**`check_ctoken_owner()`** - Compression operations authorization: +``` +Location: src/shared/owner_validation.rs:83-113 +Checks: +1. Authority account must be signer -> InvalidSigner +2. Authority matches owner -> OK +3. Authority matches permanent delegate -> OK +Error: ErrorCode::OwnerMismatch (if neither matches) +``` + +**`check_token_program_owner()`** - Token program ownership: +``` +Location: src/shared/owner_validation.rs:14-25 +Accepts: SPL Token | Token-2022 | CToken program +Error: ProgramError::IncorrectProgramId +``` + +--- + +## 3. Instructions by Category + +### 3.1 Account Management + +#### 3.1.1 CreateTokenAccount (Discriminator: 18) + +**Source:** `src/ctoken/create.rs:21-108` +**Enum:** `InstructionType::CreateTokenAccount` + +##### Account Layout + +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | token_account | Y | Conditional* | `next_signer_mut()` or `next_mut()` | 20002/20009 | +| 1 | mint | N | N | `next_non_mut()` | 20005 | +| 2** | payer | Y | Y | `next_signer_mut()` | 20002/20009 | +| 3** | config_account | N | N | `next_config_account()` | 20001/20000 | +| 4** | system_program | N | N | `next_non_mut()` | 20005 | +| 5** | rent_payer | Y | N | `next_mut()` | 20002 | + +*Conditional: Signer required only when `compressible_config` is Some +**Accounts 2-5 only required when `compressible_config` is Some + +##### Account Details + +**[0] token_account** +- **Mutability:** Always mutable +- **Signer:** Required for compressible accounts (PDA signer), not required for non-compressible +- **Owner:** For non-compressible, must already be owned by CToken program (implicit via write) +- **Validation Code:** + ```rust + // src/ctoken/create.rs:46-50 + let token_account = if is_compressible { + iter.next_signer_mut("token_account")? + } else { + iter.next_mut("token_account")? + }; + ``` + +**[1] mint** +- **Mutability:** Read-only +- **Owner:** SPL Token or Token-2022 (checked during extension parsing) +- **Validation:** Extensions parsed via `has_mint_extensions(mint)` + +**[3] config_account (if compressible)** +- **Owner:** Registry program `Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX` +- **Discriminator:** `CompressibleConfig::LIGHT_DISCRIMINATOR` +- **State:** Must be ACTIVE +- **Validation Code:** + ```rust + // src/shared/config_account.rs + check_owner(®istry_program_id, config_account)?; + check_discriminator::(data)?; + config.validate_active()?; + ``` + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | config via `check_owner()` | Mint extensions parsed after owner implicit via program | +| Discriminator | Y | `check_discriminator::()` | Config account type verified | +| Signer check | Y | `next_signer_mut()` for compressible | Token account signer for compressible path | +| PDA verification | Y | `check_seeds()` on compress_to_pubkey | If provided, verified against token_account | +| Frontrunning | Partial | Token account address deterministic if compressible | Non-compressible can be frontrun | + +--- + +#### 3.1.2 CreateAssociatedCTokenAccount (Discriminator: 100) + +**Source:** `src/ctoken/create_ata.rs:20-142` +**Enum:** `InstructionType::CreateAssociatedCTokenAccount` + +##### Account Layout + +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | owner | N | N | `next_account()` | - | +| 1 | mint | N | N | `next_account()` | - | +| 2 | fee_payer | Y | Y | `next_signer_mut()` | 20002/20009 | +| 3 | associated_token_account | Y | N | `next_mut()` | 20002 | +| 4 | system_program | N | N | `next_non_mut()` | 20005 | +| 5* | compressible_config | N | N | `next_config_account()` | 20001/20000 | +| 6* | rent_payer | Y | N | `next_mut()` | 20002 | + +*Accounts 5-6 only required when `compressible_config` is Some + +##### Account Details + +**[3] associated_token_account** +- **Owner Check:** Must be System program (uninitialized) at `create_ata.rs:75-77` + ```rust + if !associated_token_account.is_owned_by(&[0u8; 32]) { + return Err(ProgramError::IllegalOwner); + } + ``` +- **PDA Derivation:** Verified via seeds `[owner, ctoken_program, mint, bump]` + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | System program check | Must be uninitialized | +| PDA verification | Y | ATA seeds derivation | Address deterministic | +| Frontrunning | Y | PDA + owner check | Cannot frontrun with different settings | +| Account revival | N/A | New account creation | Not applicable | + +--- + +#### 3.1.3 CreateAssociatedTokenAccountIdempotent (Discriminator: 102) + +**Source:** `src/ctoken/create_ata.rs:29-34` +**Enum:** `InstructionType::CreateAssociatedTokenAccountIdempotent` + +Same as CreateAssociatedCTokenAccount with idempotent mode enabled. + +**Additional Check for Idempotent Mode:** +```rust +// src/ctoken/create_ata.rs:67-72 +if IDEMPOTENT { + validate_ata_derivation(associated_token_account, owner_bytes, mint_bytes, bump)?; + if associated_token_account.is_owned_by(&crate::LIGHT_CPI_SIGNER.program_id) { + return Ok(()); // Already exists, return early + } +} +``` + +--- + +#### 3.1.4 CloseTokenAccount (Discriminator: 9) + +**Source:** `src/ctoken/close/processor.rs:17-30` +**Accounts:** `src/ctoken/close/accounts.rs:8-33` +**Enum:** `InstructionType::CloseTokenAccount` + +##### Account Layout + +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | token_account | Y | N | `next_mut()` + `check_owner()` | 20001/20002 | +| 1 | destination | Y | N | `next_mut()` | 20002 | +| 2 | authority | N | Y | `next_signer()` | 20009 | +| 3* | rent_sponsor | Y | N | `next_mut()` | 20002 | + +*rent_sponsor required only if token_account has Compressible extension + +##### Account Details + +**[0] token_account** +- **Owner:** CToken program +- **Discriminator:** Implicitly checked via `CToken::from_account_info_mut_checked()` +- **Validation Code:** + ```rust + // src/ctoken/close/accounts.rs:20-21 + let token_account = iter.next_mut("token_account")?; + check_owner(&LIGHT_CPI_SIGNER.program_id, token_account)?; + ``` + +**[2] authority** +- **Authorization:** Must match close_authority (if set) OR owner +- **Signer Check:** `check_signer(accounts.authority)` at `close/processor.rs:111` +- **Validation Code:** + ```rust + // src/ctoken/close/processor.rs:78-98 + if let Some(close_authority) = ctoken.close_authority() { + if !pubkey_eq(ctoken.close_authority.array_ref(), accounts.authority.key()) { + return Err(ErrorCode::OwnerMismatch.into()); + } + } else { + if !pubkey_eq(ctoken.owner.array_ref(), accounts.authority.key()) { + return Err(ErrorCode::OwnerMismatch.into()); + } + } + ``` + +**[3] rent_sponsor (if compressible)** +- **Validation:** Must match `compression.info.rent_sponsor` stored in token account +- **Code:** + ```rust + // src/ctoken/close/processor.rs:60-63 + if compression.info.rent_sponsor != *rent_sponsor.key() { + return Err(ProgramError::InvalidAccountData); + } + ``` + +##### State Validations + +| Check | Location | Error | +|-------|----------|-------| +| Balance == 0 | `processor.rs:44-46` | `NonNativeHasBalance` | +| State != Frozen | `processor.rs:70-74` | `AccountFrozen` | +| State != Uninitialized | `processor.rs:73` | `UninitializedAccount` | +| Destination != token_account | `processor.rs:39-41` | `InvalidAccountData` | + +##### Account Closure Procedure (Tip 40 Compliance) + +```rust +// src/ctoken/close/processor.rs:197-204 +fn finalize_account_closure(accounts: &CloseTokenAccountAccounts<'_>) -> Result<(), ProgramError> { + unsafe { + accounts.token_account.assign(&[0u8; 32]); // Reassign to System program + } + accounts.token_account.resize(0)?; // Reallocate to 0 bytes + Ok(()) +} +``` + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | `check_owner()` | CToken program ownership verified | +| Discriminator | Y | `from_account_info_mut_checked()` | Implicit in zero-copy parse | +| Signer check | Y | `check_signer(authority)` | Authority must sign | +| Account revival | Y | `assign() + resize(0)` | Proper closure procedure | +| TOCTOU | Y | Balance check at close time | Balance verified before close | + +--- + +### 3.2 CToken Operations + +#### 3.2.1 CTokenTransfer (Discriminator: 3) + +**Source:** `src/ctoken/transfer/default.rs` +**Shared Logic:** `src/ctoken/transfer/shared.rs` +**Enum:** `InstructionType::CTokenTransfer` + +##### Account Layout + +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | source | Y | N | Via pinocchio_token_program | SPL errors | +| 1 | destination | Y | N | Via pinocchio_token_program | SPL errors | +| 2 | authority | N | Y | Via pinocchio_token_program + extension validation | SPL/20009 | +| 3* | payer | Y | Y | For top-up if needed | 18061 | + +*payer required if source or destination has Compressible extension needing top-up + +##### Delegation to pinocchio_token_program + +CTokenTransfer delegates core validation to `pinocchio_token_program::processor::transfer::process_transfer()` which performs: +- Source/destination owner check (CToken program) +- Source/destination mint match +- Source balance check +- Authority is owner OR delegate with sufficient delegated_amount +- Source not frozen + +##### Extension Validation (shared.rs) + +**Sender Validation:** +```rust +// src/ctoken/transfer/shared.rs:152-183 +let sender_info = process_account_extensions(source, &mut current_slot, mint)?; + +// For restricted extensions, mint is required +if sender_info.flags.has_restricted_extensions() { + let mint_account = transfer_accounts.mint.ok_or(ErrorCode::MintRequiredForTransfer)?; + Some(check_mint_extensions(mint_account, deny_restricted_extensions)?) +} +``` + +**Permanent Delegate Validation:** +```rust +// src/ctoken/transfer/shared.rs:197-214 +fn validate_permanent_delegate(mint_checks: Option<&MintExtensionChecks>, authority: &AccountInfo) -> Result { + // If permanent_delegate matches authority and is signer -> skip pinocchio validation + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + Ok(true) +} +``` + +**T22 Extension Consistency:** +```rust +// src/ctoken/transfer/shared.rs:32-43 +fn check_t22_extensions(&self, other: &Self) -> Result<(), ProgramError> { + if self.flags.has_pausable != other.flags.has_pausable + || self.flags.has_permanent_delegate != other.flags.has_permanent_delegate + || ... { + Err(ProgramError::InvalidInstructionData) + } +} +``` + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | pinocchio + `from_account_info_mut_checked()` | CToken ownership via pinocchio | +| Signer check | Y | pinocchio + `validate_permanent_delegate()` | Multiple auth paths | +| TOCTOU | Y | Amount in instruction data | Fixed amount at call time | +| Duplicate accounts | Y | pinocchio handles | Same-account transfer checked | + +--- + +#### 3.2.2 CTokenTransferChecked (Discriminator: 12) + +**Source:** `src/ctoken/transfer/checked.rs` +**Enum:** `InstructionType::CTokenTransferChecked` + +Same as CTokenTransfer with additional: +- Mint account required (not optional) +- Decimals validation via pinocchio_token_program +- Restricted extensions ALLOWED (unlike CTokenTransfer which blocks them) + +--- + +#### 3.2.3 CTokenApprove (Discriminator: 4) + +**Source:** `src/ctoken/approve_revoke.rs:28-41` +**Enum:** `InstructionType::CTokenApprove` + +##### Account Layout + +| Index | Account | Checks | Notes | +|-------|---------|--------|-------| +| 0 | source | Via pinocchio | Token account to approve | +| 1 | delegate | Via pinocchio | Account receiving delegation | +| 2* | payer | Signer + mutable | For compressible top-up | + +##### Delegation to pinocchio_token_program + +```rust +// src/ctoken/approve_revoke.rs:38-39 +process_approve(accounts, &instruction_data[..APPROVE_BASE_LEN]) + .map_err(convert_pinocchio_token_error)?; +``` + +pinocchio_token_program validates: +- Source account owned by CToken program +- Authority matches source.owner +- Authority is signer +- Source not frozen + +--- + +#### 3.2.4 CTokenRevoke (Discriminator: 5) + +**Source:** `src/ctoken/approve_revoke.rs:50-59` +**Enum:** `InstructionType::CTokenRevoke` + +##### Account Layout + +| Index | Account | Checks | Notes | +|-------|---------|--------|-------| +| 0 | source | Via pinocchio | Token account to revoke | +| 1* | payer | Signer + mutable | For compressible top-up | + +##### Delegation to pinocchio_token_program + +```rust +process_revoke(accounts).map_err(convert_pinocchio_token_error)?; +``` + +--- + +#### 3.2.5 CTokenMintTo (Discriminator: 7) + +**Source:** `src/ctoken/mint_to.rs` +**Enum:** `InstructionType::CTokenMintTo` + +Delegates to pinocchio_token_program::processor::mint_to::process_mint_to() + +Validation: +- Mint owned by Token program +- Authority matches mint.mint_authority +- Authority is signer +- Destination owned by CToken program +- Destination.mint matches mint + +--- + +#### 3.2.6 CTokenMintToChecked (Discriminator: 14) + +Same as CTokenMintTo with decimals validation. + +--- + +#### 3.2.7 CTokenBurn (Discriminator: 8) + +**Source:** `src/ctoken/burn.rs` +**Enum:** `InstructionType::CTokenBurn` + +Delegates to pinocchio_token_program::processor::burn::process_burn() + +Validation: +- Source owned by CToken program +- Authority matches source.owner OR source.delegate +- Authority is signer +- Source not frozen +- Sufficient balance/delegated_amount + +--- + +#### 3.2.8 CTokenBurnChecked (Discriminator: 15) + +Same as CTokenBurn with decimals validation. + +--- + +#### 3.2.9 CTokenFreezeAccount (Discriminator: 10) + +**Source:** `src/ctoken/freeze_thaw.rs` +**Enum:** `InstructionType::CTokenFreezeAccount` + +Delegates to pinocchio_token_program::processor::freeze_account::process_freeze_account() + +Validation: +- Account owned by CToken program +- Mint owned by Token program +- Authority matches mint.freeze_authority +- Authority is signer +- Account.mint matches mint + +--- + +#### 3.2.10 CTokenThawAccount (Discriminator: 11) + +**Source:** `src/ctoken/freeze_thaw.rs` +**Enum:** `InstructionType::CTokenThawAccount` + +Delegates to pinocchio_token_program::processor::thaw_account::process_thaw_account() + +Same validation as FreezeAccount. + +--- + +### 3.3 Compressed Token Operations + +#### 3.3.1 Transfer2 (Discriminator: 101) + +**Source:** `src/compressed_token/transfer2/processor.rs` +**Accounts:** `src/compressed_token/transfer2/accounts.rs` +**Enum:** `InstructionType::Transfer2` + +This is the most complex instruction supporting: +- Compressed-to-compressed transfers +- Compress (SPL/CToken -> compressed) +- Decompress (compressed -> CToken) +- CompressAndClose (CToken -> compressed + close) + +##### Account Layout (varies by mode) + +**Mode 1: No compressed accounts (compressions only)** +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | cpi_authority_pda | N | N | `next_account()` | - | +| 1 | fee_payer | N | Y | `next_signer()` | 20009 | +| 2+ | packed_accounts | - | - | `remaining()` | - | + +**Mode 2: With CPI context write** +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | light_system_program | N | N | `next_non_mut()` | 20005 | +| 1+ | CpiContextLightSystemAccounts | - | - | Various | - | +| N+ | packed_accounts | - | - | `remaining()` | - | + +**Mode 3: Standard execution** +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | light_system_program | N | N | `next_non_mut()` | 20005 | +| 1+ | LightSystemAccounts | - | - | Various | - | +| N+ | packed_accounts | - | - | `remaining()` | - | + +##### LightSystemAccounts Validation (`src/shared/accounts.rs`) + +| Field | Check | Error | +|-------|-------|-------| +| fee_payer | `next_signer_mut()` | 20002/20009 | +| cpi_authority_pda | `next_non_mut()` | 20005 | +| registered_program_pda | `next_non_mut()` | 20005 | +| account_compression_authority | `next_non_mut()` | 20005 | +| account_compression_program | `next_non_mut()` | 20005 | +| system_program | `next_non_mut()` | 20005 | +| sol_pool_pda (optional) | `next_option()` | - | +| sol_decompression_recipient (optional) | `next_option()` | - | +| cpi_context (optional) | `next_option_mut()` | - | + +##### Input Token Data Validation (`src/shared/token_input.rs:30-159`) + +```rust +// Index-based account retrieval from packed_accounts +let owner_account = packed_accounts.get(input_token_data.owner as usize)?; +let delegate_account = packed_accounts.get(input_token_data.delegate as usize)?; +let mint_account = packed_accounts.get(input_token_data.mint as usize)?; + +// ATA derivation check for is_ata=true (lines 191-238) +if data.is_ata() { + let wallet_owner = packed_accounts.get(data.owner_index as usize)?; + let derived_ata = create_program_address(&ata_seeds, &program_id)?; + if !pubkey_eq(owner_account.key(), &derived_ata) { + return Err(CTokenError::InvalidAtaDerivation.into()); + } +} + +// Authority validation (lines 89-94) +verify_owner_or_delegate_signer(signer_account, delegate_account, permanent_delegate, all_accounts)?; +``` + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | token_input.rs via packed_accounts | Index-based, bounds-checked | +| PDA verification | Y | resolve_ata_signer() | ATA derivation verified | +| Signer check | Y | verify_owner_or_delegate_signer() | Multi-auth supported | +| TOCTOU | Y | Amount in instruction data | Fixed at call time | +| Duplicate accounts | N/A | Light system handles | Via CPI | + +--- + +#### 3.3.2 MintAction (Discriminator: 103) + +**Source:** `src/compressed_token/mint_action/processor.rs` +**Accounts:** `src/compressed_token/mint_action/accounts.rs` +**Enum:** `InstructionType::MintAction` + +Supports 10 action types: +1. CreateCompressedMint +2. MintToCompressed +3. MintToCToken +4. UpdateMintAuthority +5. UpdateFreezeAuthority +6. UpdateMetadataField +7. UpdateMetadataAuthority +8. RemoveMetadataKey +9. DecompressMint +10. CompressAndCloseCMint + +##### Account Layout (varies by action) + +| Index | Account | Mut | Signer | Condition | Checks | +|-------|---------|-----|--------|-----------|--------| +| 0 | light_system_program | N | N | Always | `next_account()` | +| 1 | mint_signer | N | Conditional* | `with_mint_signer` | `next_option_signer()` or `next_option()` | +| 2 | authority | N | Y | Always | `next_signer()` | +| 3 | compressible_config | N | N | `needs_compressible_accounts()` | `next_config_account()` | +| 4 | cmint | Y | N | `needs_cmint_account()` | `next_option_mut()` | +| 5 | rent_sponsor | Y | N | `needs_compressible_accounts()` | `next_option_mut()` | +| 6+ | LightSystemAccounts | - | - | Not write_to_cpi_context | Various | +| N | out_output_queue | N | N | Not write_to_cpi_context | `next_account()` | +| N+1 | address_merkle_tree OR in_merkle_tree | N | N | Depends on `create_mint` | `next_account()` | +| N+2 | in_output_queue | N | N | Not `create_mint` | `next_option()` | +| N+3 | tokens_out_queue | N | N | `has_mint_to_actions` | `next_option()` | +| N+4+ | packed_accounts (tree + recipient accounts) | - | - | - | `remaining_unchecked()` | + +*mint_signer must sign only for CreateCompressedMint, not for DecompressMint + +##### Key Validations (`src/compressed_token/mint_action/accounts.rs`) + +**mint_signer:** +```rust +// Line 79-83: Signer required for create_mint only +let mint_signer = if config.mint_signer_must_sign() { + iter.next_option_signer("mint_signer", config.with_mint_signer)? +} else { + iter.next_option("mint_signer", config.with_mint_signer)? +}; +``` + +**authority:** +```rust +// Line 86: Always required to sign +let authority = iter.next_signer("authority")?; +``` + +**Address Merkle Tree Validation:** +```rust +// Line 325-333: Must match expected CMINT_ADDRESS_TREE +if let Some(address_tree) = accounts.address_merkle_tree { + if *address_tree.key() != CMINT_ADDRESS_TREE { + return Err(ErrorCode::InvalidAddressTree.into()); + } +} +``` + +**CMint Account Match:** +```rust +// Line 318-322: Verify CMint matches expected pubkey +if let (Some(cmint_account), Some(expected_pubkey)) = (accounts.cmint, cmint_pubkey) { + if expected_pubkey.to_bytes() != *cmint_account.key() { + return Err(ErrorCode::MintAccountMismatch.into()); + } +} +``` + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | CMint via `zero_copy_at_mut_checked()` | Owner implicit via parse | +| Discriminator | Y | Zero-copy parsing | CMint discriminator checked | +| Signer check | Y | `next_signer()` for authority | Always required | +| PDA verification | Y | `CMINT_ADDRESS_TREE` constant | Fixed address tree | +| CPI program check | Y | Light system hardcoded | Via constant | + +--- + +### 3.4 Compressible Rent Management + +#### 3.4.1 Claim (Discriminator: 104) + +**Source:** `src/compressible/claim.rs` +**Enum:** `InstructionType::Claim` + +##### Account Layout + +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | rent_sponsor | Y | N | `next_mut()` | 20002 | +| 1 | compression_authority | N | Y | `next_signer()` | 20009 | +| 2 | config_account | N | N | `parse_config_account()` | 20001/20000 | +| 3+ | token_accounts | Y | N | `check_owner()` per account | 20001 | + +##### Account Details + +**[0] rent_sponsor** +- Must match `config_account.rent_sponsor` +- Validation at `claim.rs:45-48` + +**[1] compression_authority** +- Must be signer +- Must match `config_account.compression_authority` +- Validation at `claim.rs:41-44` + +**[2] config_account** +- Owner: Registry program `Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX` +- Discriminator: `CompressibleConfig::LIGHT_DISCRIMINATOR` +- State: Not INACTIVE (`validate_not_inactive()` at line 37-39) + +**[3+] token_accounts (variable)** +- Each validated by `check_owner(&LIGHT_CPI_SIGNER.program_id, account)` at line 114 +- Must have Compressible or CMint extension with matching rent_sponsor +- Account type determined by size: 165 bytes = CToken, >165 bytes = check byte 165 + +##### State Validations (`claim.rs:107-160`) + +```rust +// Account type determination (lines 97-105) +fn determine_account_type(data: &[u8]) -> Result { + match data.len().cmp(&165) { + Less => Err(InvalidAccountData), + Equal => Ok(ACCOUNT_TYPE_TOKEN_ACCOUNT), + Greater => Ok(data[165]) + } +} + +// For CToken accounts +let compressible = ctoken.get_compressible_extension_mut() + .ok_or(CTokenError::MissingCompressibleExtension)?; + +// For CMint accounts +cmint.base.compression.claim_and_update(claim_and_update)?; +``` + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | `check_owner()` per token account | Line 114 | +| Discriminator | Y | `zero_copy_at_mut_checked()` | Implicit via parse | +| Signer check | Y | `next_signer()` | compression_authority | +| Authority match | Y | `compression_authority == config.compression_authority` | Line 41-44 | +| Rent sponsor match | Y | `rent_sponsor == config.rent_sponsor` | Line 45-48 | + +--- + +#### 3.4.2 WithdrawFundingPool (Discriminator: 105) + +**Source:** `src/compressible/withdraw_funding_pool.rs` +**Enum:** `InstructionType::WithdrawFundingPool` + +##### Account Layout + +| Index | Account | Mut | Signer | Checks | Error | +|-------|---------|-----|--------|--------|-------| +| 0 | rent_sponsor | Y | N | `next_mut()` + config match | 20002 | +| 1 | compression_authority | N | Y | `next_signer()` + config match | 20009 | +| 2 | destination | Y | N | `next_mut()` | 20002 | +| 3 | system_program | N | N | `next_account()` | - | +| 4 | config | N | N | `parse_config_account()` | 20001/20000 | + +##### Account Details + +**[0] rent_sponsor** +- Must match `config_account.rent_sponsor` +- PDA derived with seeds `[b"rent_sponsor", version_bytes, bump]` +- Used for `invoke_signed` transfer + +**[1] compression_authority** +- Must be signer +- Must match `config_account.compression_authority` +- Validation at line 46-49 + +**[4] config** +- Owner: Registry program +- Discriminator: CompressibleConfig +- State: Not INACTIVE + +##### CPI Verification (Tip 27 Compliance) + +```rust +// Lines 110-118: System program transfer via invoke_signed +let transfer = Transfer { + from: accounts.rent_sponsor, + to: accounts.destination, + lamports: amount, +}; +transfer.invoke_signed(&[signer]).map_err(convert_program_error) +``` + +The system program is passed as an account but the `pinocchio_system::Transfer` instruction uses the hardcoded system program ID internally, so CPI program verification is implicit. + +##### Security Analysis + +| Attack Pattern | Protected | Check Location | Notes | +|----------------|-----------|----------------|-------| +| Owner before read | Y | `parse_config_account()` checks owner | Config account | +| Signer check | Y | `next_signer()` | compression_authority | +| Authority match | Y | config.compression_authority comparison | Line 46-49 | +| Rent sponsor match | Y | config.rent_sponsor comparison | Line 50-53 | +| CPI program check | Y | pinocchio_system hardcoded | Implicit | +| Balance check | Y | `pool_lamports < amount` | Line 91-99 | + +--- + +### 3.5 Token Pool Operations (Anchor) + +The following instructions use Anchor framework and are defined in `programs/compressed-token/anchor/src/lib.rs`. + +Anchor provides automatic validation through the `#[derive(Accounts)]` macro: +- `#[account(mut)]` - Mutability check +- `#[account(signer)]` - Signer check +- `#[account(owner = X)]` - Owner check +- `#[account(constraint = X)]` - Custom constraint +- `has_one = X` - Field match check +- `seeds = [...]` - PDA derivation check +- `init` - Initialize new account with size/owner + +**Note:** Anchor instructions primarily operate on compressed accounts via CPI to the Light System Program. The account validation for compressed account operations is handled by the light-system-program. + +#### 3.5.1 CreateTokenPool + +**Source:** `programs/compressed-token/anchor/src/lib.rs:50-63` + +Creates a token pool for SPL token compression. Each SPL mint can have one primary token pool. + +**Accounts:** `CreateTokenPoolInstruction` +- `fee_payer` - Signer, mutable (pays for account) +- `token_pool_pda` - Mutable, initialized via PDA `[b"pool", mint]` +- `mint` - SPL Token/Token-2022 mint +- `system_program` - System program +- `token_program` - SPL Token or Token-2022 program +- `cpi_authority_pda` - PDA authority for the pool + +**Validation:** +- Mint extensions checked via `assert_mint_extensions()` +- Token account initialized via CPI to token program + +#### 3.5.2 AddTokenPool + +**Source:** `programs/compressed-token/anchor/src/lib.rs:68-95` + +Creates additional token pools (max 5 per mint). + +**Accounts:** `AddTokenPoolInstruction` +- `fee_payer` - Signer, mutable +- `token_pool_pda` - New pool PDA with index +- `existing_token_pool_pda` - Previous pool (must exist) +- `mint` - SPL Token/Token-2022 mint +- `system_program` - System program +- `token_program` - SPL Token or Token-2022 program +- `cpi_authority_pda` - PDA authority + +**Validation:** +- `token_pool_index >= NUM_MAX_POOL_ACCOUNTS` (5) -> `InvalidTokenPoolBump` +- Previous pool PDA validated via `is_valid_spl_interface_pda()` + +#### 3.5.3 MintTo (Anchor) + +**Source:** `programs/compressed-token/anchor/src/lib.rs:104-118` + +Mints SPL tokens to compressed accounts. + +**Accounts:** `MintToInstruction` +- `fee_payer` - Signer, mutable +- `authority` - Mint authority signer +- `mint` - SPL mint (has_one = authority) +- `token_pool_pda` - Token pool +- `token_program` - Token program +- Remaining accounts for Light System CPI + +**Validation:** +- Authority must match mint.mint_authority (Anchor `has_one`) +- Tokens transferred to pool, compressed equivalents created + +#### 3.5.4 BatchCompress + +**Source:** `programs/compressed-token/anchor/src/lib.rs:121-146` + +Batch compress tokens to multiple recipients. + +**Accounts:** Same as `MintToInstruction` + +**Validation:** +- Cannot have both `amounts` and `amount` in instruction data -> `AmountsAndAmountProvided` +- Must have either `amounts` or `amount` -> `NoAmount` + +#### 3.5.5 CompressSplTokenAccount + +**Source:** `programs/compressed-token/anchor/src/lib.rs:151-158` + +Compresses SPL token account balance to compressed tokens. + +**Accounts:** `TransferInstruction` +- `fee_payer` - Signer, mutable +- `authority` - Token account authority signer +- `compress_or_decompress_token_account` - SPL token account +- `token_pool_pda` - Token pool +- `token_program` - Token program +- Remaining accounts for Light System CPI + +**Validation:** +- Authority must be owner of compress_or_decompress_token_account +- Sufficient balance for compression + +#### 3.5.6 Transfer (Anchor) + +**Source:** `programs/compressed-token/anchor/src/lib.rs:168-182` + +Transfers compressed tokens with optional compression/decompression. + +**Accounts:** `TransferInstruction` + +**Validation:** +- CPI context validated if compression/decompression (`check_cpi_context()`) +- Sum checks performed (inputs = outputs + compression/decompression) + +#### 3.5.7 Approve (Anchor) + +**Source:** `programs/compressed-token/anchor/src/lib.rs:190-195` + +Delegates compressed tokens. + +**Accounts:** `GenericInstruction` +- Standard Light System accounts for compressed account operations + +**Validation:** +- Owner must sign (via compressed account proof) +- Creates delegated output + change output + +#### 3.5.8 Revoke (Anchor) + +**Source:** `programs/compressed-token/anchor/src/lib.rs:199-204` + +Revokes delegation on compressed tokens. + +**Accounts:** `GenericInstruction` + +**Validation:** +- Owner must sign (not delegate) +- Merges all inputs into single undelegated output + +#### 3.5.9 Freeze (Anchor) + +**Source:** `programs/compressed-token/anchor/src/lib.rs:208-214` + +Freezes compressed token accounts. + +**Accounts:** `FreezeInstruction` +- `authority` - Freeze authority signer +- `mint` - SPL mint with freeze authority +- Remaining accounts for Light System CPI + +**Validation:** +- Input accounts must NOT be frozen +- Authority must match mint.freeze_authority + +#### 3.5.10 Thaw (Anchor) + +**Source:** `programs/compressed-token/anchor/src/lib.rs:218-224` + +Thaws frozen compressed token accounts. + +**Accounts:** `FreezeInstruction` + +**Validation:** +- Input accounts must BE frozen +- Authority must match mint.freeze_authority + +#### 3.5.11 Burn (Anchor) + +**Source:** `programs/compressed-token/anchor/src/lib.rs:229-234` + +Burns compressed tokens. + +**Accounts:** `BurnInstruction` +- `authority` - Owner or delegate signer +- `mint` - SPL mint +- `token_pool_pda` - Token pool (for SPL token burn) +- `token_program` - Token program +- Remaining accounts for Light System CPI + +**Validation:** +- Delegates can burn (output remains delegated) +- SPL tokens burned from pool account + +--- + +## 4. Potential Gaps and Recommendations + +During this security review, the following areas were identified for potential hardening: + +### 4.1 Packed Accounts Owner Validation + +**Location:** `src/shared/token_input.rs`, `src/compressed_token/transfer2/accounts.rs` + +**Observation:** Accounts retrieved from `packed_accounts` by index (owner, delegate, mint) do not have explicit `check_owner()` calls at retrieval time. Validation relies on zero-copy parsing to fail if data is invalid. + +**Current Protection:** Implicit via CToken/CMint deserialization checks. + +**Recommendation:** Consider adding explicit owner checks for mint accounts before parsing extensions. + +### 4.2 Tree Account Identification Heuristic + +**Location:** `src/compressed_token/transfer2/accounts.rs:147-155` + +```rust +// Checks first 8 bytes of owner (account-compression program prefix) +if account_info.owner()[0..8] == [9, 44, 54, 236, 34, 245, 23, 131] { +``` + +**Status:** Acceptable - the 8-byte prefix of the account-compression program ID is unique and a collision is practically impossible. This optimization reduces compute cost without sacrificing security. + +### 4.3 Non-Compressible CreateTokenAccount + +**Location:** `src/ctoken/create.rs:49` + +**Observation:** In non-compressible path, `token_account` fetched via `next_mut()` without explicit owner check. Comment states "ownership is implicitly validated when writing." + +**Current Protection:** Solana runtime enforces owner-only writes. + +**Status:** Acceptable - runtime protection adequate. + +### 4.4 Areas with Strong Protection + +The following areas have robust account validation: + +1. **CloseTokenAccount** - Proper `check_owner()`, `check_signer()`, and account revival prevention (`assign()` + `resize(0)`) + +2. **Config Account Validation** - Full owner + discriminator + state validation via `parse_config_account()` + +3. **Authority Validation** - `verify_owner_or_delegate_signer()` covers owner, delegate, and permanent delegate with proper signer checks + +4. **ATA Derivation** - `resolve_ata_signer()` in token_input.rs properly validates PDA derivation + +5. **CPI Safety** - All CPI calls use hardcoded program IDs (light_system_program, pinocchio_system, pinocchio_token_program) + +--- + +## 5. Quick Reference Tables + +### 5.1 Account Checks by Instruction + +| Instruction | Disc | Accounts | Signers | Owner Checks | PDA Checks | +|-------------|------|----------|---------|--------------|------------| +| CreateTokenAccount | 18 | 2-6 | 1-2 | config | compress_to_pubkey | +| CreateAssociatedCTokenAccount | 100 | 5-7 | 1 | system, config | ATA derivation | +| CreateAssociatedTokenAccountIdempotent | 102 | 5-7 | 1 | system/ctoken, config | ATA derivation | +| CloseTokenAccount | 9 | 3-4 | 1 | ctoken | - | +| CTokenTransfer | 3 | 3-4 | 1 | ctoken (via pinocchio) | - | +| CTokenTransferChecked | 12 | 4-5 | 1 | ctoken, token (via pinocchio) | - | +| CTokenApprove | 4 | 2-3 | 1 | ctoken (via pinocchio) | - | +| CTokenRevoke | 5 | 1-2 | 1 | ctoken (via pinocchio) | - | +| CTokenMintTo | 7 | 3-4 | 1 | token, ctoken (via pinocchio) | - | +| CTokenMintToChecked | 14 | 3-4 | 1 | token, ctoken (via pinocchio) | - | +| CTokenBurn | 8 | 3-4 | 1 | token, ctoken (via pinocchio) | - | +| CTokenBurnChecked | 15 | 3-4 | 1 | token, ctoken (via pinocchio) | - | +| CTokenFreezeAccount | 10 | 3 | 1 | token, ctoken (via pinocchio) | - | +| CTokenThawAccount | 11 | 3 | 1 | token, ctoken (via pinocchio) | - | +| Transfer2 | 101 | Variable | 1+ | ctoken, light_system | Various | +| MintAction | 103 | Variable | 1+ | ctoken, light_system | Various | +| Claim | 104 | 3+ | 1 | ctoken, registry | - | +| WithdrawFundingPool | 105 | 5 | 1 | registry | rent_sponsor PDA | + +### 5.2 Security Checklist Summary + +| Attack Pattern | Protection Method | Key Locations | +|----------------|-------------------|---------------| +| Owner before read | `check_owner()`, pinocchio validation | All instruction entry points | +| Discriminator | `check_discriminator()`, zero-copy parse | Account deserialization | +| Signer verification | `check_signer()`, `next_signer*()` | Authority accounts | +| PDA verification | `check_pda_seeds()`, `validate_ata_derivation()` | ATA, rent_sponsor | +| Account revival | `assign()` + `resize(0)` | CloseTokenAccount | +| TOCTOU | Amount in instruction data | Transfer, Burn, Approve | +| CPI program check | pinocchio_token_program hardcoded | All CPI calls | +| Duplicate accounts | pinocchio handles | Transfer operations | + +--- + +## Appendix + +### A. File Reference + +**Validation Primitives:** +- `program-libs/account-checks/src/checks.rs` +- `program-libs/account-checks/src/account_iterator.rs` +- `program-libs/account-checks/src/error.rs` + +**Token-Specific Validation:** +- `programs/compressed-token/program/src/shared/owner_validation.rs` +- `programs/compressed-token/program/src/shared/token_input.rs` +- `programs/compressed-token/program/src/shared/config_account.rs` +- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs` + +**Instruction Processors:** +- `programs/compressed-token/program/src/ctoken/` - CToken operations +- `programs/compressed-token/program/src/compressed_token/` - Compressed operations +- `programs/compressed-token/program/src/compressible/` - Rent management +- `programs/compressed-token/anchor/src/lib.rs` - Anchor instructions diff --git a/programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md b/programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md new file mode 100644 index 0000000000..0e5a8c360a --- /dev/null +++ b/programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md @@ -0,0 +1,458 @@ +# Compressible ATA Lifecycle + +This document describes the complete lifecycle of a compressible Associated Token Account (ATA) from creation through compress-and-close to decompression. + +## Overview + +A **compressible ATA** is an Associated Token Account with the Compressible extension enabled. Unlike regular ATAs or regular CToken accounts, compressible ATAs support rent management and can be compressed to save on-chain storage costs. + +**Key characteristics:** +- PDA derived from `[wallet_owner, ctoken_program_id, mint, bump]` +- Always has `compression_only` flag set (required) +- Cannot use `compress_to_pubkey` (ATAs always compress to owner) +- Has `is_ata=1` flag in Compressible extension +- Supports full state preservation during compress/decompress cycles + +**Why `is_ata` matters:** +ATAs are PDAs and cannot sign transactions. During compression, the ATA pubkey becomes the "owner" of the compressed token account. The `is_ata` flag and associated `owner_index`/`bump` fields allow the system to: +1. Validate the original wallet owner during decompress +2. Verify ATA derivation to prevent spoofing +3. Route signing authority to the wallet owner instead of the ATA + +--- + +## Data Structures + +### CompressibleExtension (on CToken account) + +**Path:** `program-libs/ctoken-interface/src/state/extensions/compressible.rs` + +```rust +pub struct CompressibleExtension { + pub decimals_option: u8, // Whether decimals are cached + pub decimals: u8, // Cached decimals value + pub compression_only: bool, // Must be true for ATAs + pub is_ata: u8, // 1=ATA, 0=regular account + pub info: CompressionInfo, // Rent management data +} +``` + +The `is_ata` field is set to `1` during ATA creation and is used during CompressAndClose to determine owner validation behavior. + +### CompressedOnlyExtension (on compressed account TLV) + +**Path:** `program-libs/ctoken-interface/src/state/extensions/compressed_only.rs` + +```rust +pub struct CompressedOnlyExtension { + pub delegated_amount: u64, // Preserved delegated amount + pub withheld_transfer_fee: u64, // Preserved withheld fees + pub is_ata: u8, // ATA flag (1 or 0) +} +``` + +This extension is stored in the compressed token account's TLV data and preserves account state during compression. + +### CompressedOnlyExtensionInstructionData (passed in instruction) + +**Path:** `program-libs/ctoken-interface/src/instructions/extensions/compressed_only.rs` + +```rust +pub struct CompressedOnlyExtensionInstructionData { + pub delegated_amount: u64, + pub withheld_transfer_fee: u64, + pub is_frozen: bool, // Whether source was frozen + pub compression_index: u8, // Index of compression operation + pub is_ata: bool, // ATA flag + pub bump: u8, // ATA PDA derivation bump + pub owner_index: u8, // Index to wallet owner in packed accounts +} +``` + +The `bump` and `owner_index` fields are only used when `is_ata=true` to verify ATA derivation during decompress. + +--- + +## 1. ATA Creation + +**Path:** `programs/compressed-token/program/src/ctoken/create_ata.rs` + +### Instruction: CreateAssociatedCTokenAccount (100) / CreateAssociatedTokenAccountIdempotent (102) + +### Accounts +1. associated_token_account (mutable) - The ATA to create +2. fee_payer (signer, mutable) - Pays transaction fees +3. owner - Wallet owner of the ATA +4. mint - Token mint account +5. system_program +6. compressible_config (optional) - Required for compressible ATAs +7. rent_payer (optional) - Custom rent payer or uses config.rent_sponsor + +### Validation Checks + +| Check | Error | +|-------|-------| +| Account owned by system program (uninitialized) | `IllegalOwner` | +| `compress_to_account_pubkey` is None | `InvalidInstructionData` | +| `compression_only != 0` | `AtaRequiresCompressionOnly` | +| CompressibleConfig is ACTIVE | `InvalidState` | +| `rent_payment != 1` epoch | `OneEpochPrefundingNotAllowed` | +| PDA derivation correct (idempotent mode) | Validated by `validate_ata_derivation` | + +### Creation Flow + +1. **Derive ATA address:** + ```rust + let (ata_pubkey, bump) = Pubkey::find_program_address( + &[owner.key(), CTOKEN_PROGRAM_ID.as_ref(), mint.key()], + &CTOKEN_PROGRAM_ID + ); + ``` + +2. **Validate compressible requirements:** + - `compression_only` must be non-zero + - `compress_to_account_pubkey` must be None (ATAs compress to owner automatically) + +3. **Calculate account size:** Includes Compressible extension and any mint extension markers + +4. **Calculate rent:** rent_exemption + prepaid_epochs_rent + compression_incentive + +5. **Create account:** Via CPI with rent_sponsor PDA or custom rent payer + +6. **Initialize CToken:** Sets `is_ata=1` in Compressible extension + +**Path:** `programs/compressed-token/program/src/shared/initialize_ctoken_account.rs` (line 300) +```rust +compressible_ext.is_ata = is_ata as u8; // Set from create_ata parameter +``` + +--- + +## 2. CompressAndClose + +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs` + +### Instruction: Transfer2 (101) with CompressionMode::CompressAndClose + +CompressAndClose closes a CToken account and creates a compressed token account. For ATAs, this requires special handling because the ATA pubkey becomes the owner of the compressed account. + +### Owner Validation (lines 103-115) + +For ATAs, the expected owner is the **ATA pubkey** (not the wallet owner): + +```rust +let expected_owner = if compression.info.compress_to_pubkey() || compression.is_ata() { + token_account_pubkey // ATA pubkey is the owner +} else { + &ctoken.owner.to_bytes() +}; +if output_owner != expected_owner { + return Err(ErrorCode::CompressAndCloseInvalidOwner.into()); +} +``` + +### CompressedOnly Extension Requirement (lines 136-145) + +ATAs **require** the CompressedOnly extension in output TLV: + +```rust +if (compression.compression_only() || compression.is_ata()) && compression_only_ext.is_none() { + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); +} +``` + +### Data Preservation Validation (lines 170-222) + +The `validate_compressed_only_ext` function validates all preserved data: + +| Field | Validation | Error | +|-------|------------|-------| +| `delegated_amount` | Must match CToken's delegated_amount | `CompressAndCloseDelegatedAmountMismatch` (6135) | +| `delegate` | Must match if delegated_amount > 0 | `CompressAndCloseInvalidDelegate` (6136) | +| `withheld_transfer_fee` | Must match TransferFeeAccount withheld | `CompressAndCloseWithheldFeeMismatch` (6137) | +| `is_frozen` | Must match CToken state (state == 2) | `CompressAndCloseFrozenMismatch` (6138) | +| `is_ata` | Must match Compressible.is_ata | `CompressAndCloseIsAtaMismatch` (6168) | + +```rust +// is_ata validation (lines 216-219) +if compression.is_ata() != ext.is_ata() { + return Err(ErrorCode::CompressAndCloseIsAtaMismatch.into()); +} +``` + +### CToken Reset After Compression (lines 68-71) + +```rust +ctoken.base.amount.set(0); +// Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) +// This allows the close_token_account validation to pass for frozen accounts +ctoken.base.set_initialized(); +``` + +### Additional Validation + +- **Amount:** compression_amount == output_amount == ctoken.amount +- **Mint:** output mint matches ctoken mint +- **Version:** Must be ShaFlat (version=3) +- **Uniqueness:** Each CompressAndClose must use different compressed output indices + +--- + +## 3. Decompress + +**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs` + +### Instruction: Transfer2 (101) with CompressionMode::Decompress + +Decompress recreates a CToken account from a compressed token account. For ATAs, the system must verify the ATA derivation and restore the wallet owner. + +### Amount Validation (lines 31-46) + +For ATAs (and compress_to_pubkey), the amount **must match exactly**: + +```rust +if ext_data.is_ata() || compress_to_pubkey { + let input_amount: u64 = inputs.input_token_data.amount.into(); + if compression_amount != input_amount { + return Err(CTokenError::DecompressAmountMismatch.into()); + } +} +``` + +This prevents partial decompression of ATA tokens, ensuring the full balance is always decompressed together. + +### Destination Validation (lines 77-106) + +For ATAs, validation is more complex because of the owner paradox: + +```rust +fn validate_destination( + ctoken: &ZCTokenMut, + destination: &AccountInfo, + input_owner_key: &[u8; 32], + ext_data: &ZCompressedOnlyExtensionInstructionData, + packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, +) -> Result<(), ProgramError> { + // Non-ATA: simple owner match + if !ext_data.is_ata() { + if !pubkey_eq(ctoken.base.owner.array_ref(), input_owner_key) { + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + return Ok(()); + } + + // ATA: destination address == input_owner (ATA pubkey) + if !pubkey_eq(destination.key(), input_owner_key) { + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + + // ATA: wallet owner from owner_index must match CToken owner + let wallet = packed_accounts.get_u8(ext_data.owner_index, "wallet owner")?; + if !pubkey_eq(wallet.key(), ctoken.base.owner.array_ref()) { + return Err(CTokenError::DecompressDestinationMismatch.into()); + } + Ok(()) +} +``` + +### ATA Derivation Verification (Critical Security Check) + +**Path:** `programs/compressed-token/program/src/shared/token_input.rs` (lines 82-120) + +During **input processing** (before decompress.rs runs), the ATA derivation is verified: + +```rust +// For ATA decompress, verify wallet owner and ATA derivation +if data.is_ata != 0 { + // 1. Get wallet owner from owner_index + let wallet_owner = packed_accounts.get(data.owner_index as usize)?; + + // 2. Derive ATA using bump from CompressedOnly extension + let bump_seed = [data.bump]; + let ata_seeds: [&[u8]; 4] = [ + wallet_owner.key().as_ref(), + CTOKEN_PROGRAM_ID.as_ref(), + mint_account.key().as_ref(), + bump_seed.as_ref(), + ]; + let derived_ata = create_program_address(&ata_seeds, &CTOKEN_PROGRAM_ID)?; + + // 3. Verify owner_account (the ATA pubkey) matches derived address + if !pubkey_eq(owner_account.key(), &derived_ata) { + return None; // Causes signer check to fail + } + + // 4. Use wallet owner as signer (ATA can't sign) + signer_account = wallet_owner; +} + +// 5. verify_owner_or_delegate_signer validates wallet_owner is a transaction signer +``` + +**Security Properties:** +- Incorrect `owner_index`: ATA derivation fails or wallet_owner is wrong, signer check fails +- Incorrect `bump`: ATA derivation produces wrong address, validation fails +- Malicious wallet: Not a transaction signer, `verify_owner_or_delegate_signer` fails + +### State Restoration + +1. **Delegate restoration (lines 108-144):** + ```rust + if let Some(delegate_acc) = input_delegate { + ctoken.base.set_delegate(Some(Pubkey::from(*delegate_acc.key())))?; + if delegated_amount > 0 { + ctoken.base.delegated_amount.set(current + delegated_amount); + } + } + ``` + +2. **Withheld fee restoration (lines 146-171):** + ```rust + if fee > 0 { + let fee_ext = ctoken.get_transfer_fee_account_extension_mut(); + fee_ext.add_withheld_amount(fee)?; + } + ``` + +3. **Frozen state restoration (lines 64-67):** + ```rust + if ext_data.is_frozen() { + ctoken.base.set_frozen(); + } + ``` + +--- + +## Validation Summary + +### Creation Validations + +| Validation | Source | Error | +|------------|--------|-------| +| Account uninitialized | create_ata.rs | `IllegalOwner` | +| compression_only set | create_ata.rs | `AtaRequiresCompressionOnly` | +| compress_to_pubkey is None | create_ata.rs | `InvalidInstructionData` | +| Config is ACTIVE | create_ata.rs | `InvalidState` | +| rent_payment != 1 | initialize_ctoken_account.rs | `OneEpochPrefundingNotAllowed` | + +### CompressAndClose Validations + +| Validation | Source | Error | +|------------|--------|-------| +| Owner matches (ATA pubkey) | compress_and_close.rs:103-115 | `CompressAndCloseInvalidOwner` | +| CompressedOnly extension present | compress_and_close.rs:136-145 | `CompressAndCloseMissingCompressedOnlyExtension` (6133) | +| delegated_amount matches | compress_and_close.rs:177-181 | `CompressAndCloseDelegatedAmountMismatch` (6135) | +| delegate pubkey matches | compress_and_close.rs:183-194 | `CompressAndCloseInvalidDelegate` (6136) | +| withheld_transfer_fee matches | compress_and_close.rs:196-209 | `CompressAndCloseWithheldFeeMismatch` (6137) | +| is_frozen matches | compress_and_close.rs:211-214 | `CompressAndCloseFrozenMismatch` (6138) | +| is_ata matches | compress_and_close.rs:216-219 | `CompressAndCloseIsAtaMismatch` (6168) | +| Amount matches | compress_and_close.rs:117-121 | `CompressAndCloseAmountMismatch` | +| Mint matches | compress_and_close.rs:123-129 | `CompressAndCloseInvalidMint` | +| Version is ShaFlat | compress_and_close.rs:131-134 | `CompressAndCloseInvalidVersion` | + +### Decompress Validations + +| Validation | Source | Error | +|------------|--------|-------| +| Amount matches (for ATA) | decompress.rs:31-46 | `DecompressAmountMismatch` (18064) | +| Destination = input_owner (ATA pubkey) | decompress.rs:89-93 | `DecompressDestinationMismatch` (18057) | +| Wallet owner = CToken owner | decompress.rs:96-102 | `DecompressDestinationMismatch` (18057) | +| ATA derivation correct | token_input.rs:79-117 | Error during input processing | +| Delegate present if delegated_amount > 0 | decompress.rs:139-142 | `DecompressDelegatedAmountWithoutDelegate` (18059) | +| TransferFeeAccount ext if withheld_fee > 0 | decompress.rs:160-168 | `DecompressWithheldFeeWithoutExtension` (18060) | + +--- + +## Data Preservation Matrix + +| Field | Preserved | Storage Location | Restored | Notes | +|-------|-----------|------------------|----------|-------| +| is_ata | Yes | CompressedOnly.is_ata | Validated | Must match source | +| delegated_amount | Yes | CompressedOnly.delegated_amount | Yes | Restored to CToken | +| withheld_transfer_fee | Yes | CompressedOnly.withheld_transfer_fee | Yes | Restored to TransferFeeAccount ext | +| is_frozen | Yes | CompressedOnly.is_frozen | Yes | Restored via set_frozen() | +| bump | Yes | CompressedOnly.bump | Used | For ATA derivation verification | +| owner_index | Yes | CompressedOnly.owner_index | Used | Identifies wallet owner account | +| delegate pubkey | Yes | Passed as input account | Yes | Restored to CToken.delegate | +| amount | No | New from compression | N/A | Set to 0 after compress | +| close_authority | No | Not preserved | N/A | Cannot be set on ATAs anyway | + +--- + +## Security: Why is_ata Flag is Trustworthy + +The `is_ata` flag in the Compressible extension is **program-controlled** and cannot be spoofed: + +1. **Set during creation only:** The flag is set by `create_ata.rs` when creating an ATA, or `create.rs` for regular accounts +2. **Account owned by program:** CToken accounts are owned by the CToken program, preventing external modification +3. **Validated during CompressAndClose:** The `is_ata` in Compressible extension must match CompressedOnly extension (line 217) + +**Attack prevention:** +- Cannot create non-ATA with `is_ata=1`: Program controls flag during creation +- Cannot modify existing account's flag: Account is program-owned +- Cannot spoof in CompressedOnly: Must match Compressible extension + +--- + +## ATA Owner Paradox + +ATAs present a unique challenge because they are PDAs and **cannot sign transactions**. This creates a paradox during compression: + +1. **The compressed token owner must be verifiable** - so we use the ATA pubkey as owner +2. **Someone must sign the decompress transaction** - but the ATA can't sign + +**Solution:** + +The CompressedOnly extension stores: +- `owner_index`: Index to the wallet owner account in packed_accounts +- `bump`: The PDA bump for ATA derivation + +During decompress: +1. Wallet owner (from `owner_index`) provides the signature +2. System verifies: `derive_ata(wallet_owner, mint, bump) == input_owner` +3. This proves the wallet owner is the legitimate owner of the ATA + +``` +Creation: + wallet_owner + mint + bump -> ATA pubkey + | + v +Compress: [ATA as owner] + | + v +Decompress: [verify derivation] + wallet_owner (signer) <-------- owner_index + mint <-------- from input + bump <-------- from CompressedOnly + | + v + derived == input_owner? +``` + +--- + +## Error Reference + +| Error | Code | Description | +|-------|------|-------------| +| `AtaRequiresCompressionOnly` | - | ATA created without compression_only flag | +| `CompressAndCloseMissingCompressedOnlyExtension` | 6133 | ATA CompressAndClose missing required extension | +| `CompressAndCloseIsAtaMismatch` | 6168 | is_ata flag mismatch between extensions | +| `CompressAndCloseInvalidOwner` | 6089 | Owner validation failed (ATA pubkey expected) | +| `CompressAndCloseDelegatedAmountMismatch` | 6135 | Delegated amount not preserved correctly | +| `CompressAndCloseInvalidDelegate` | 6136 | Delegate pubkey mismatch | +| `CompressAndCloseWithheldFeeMismatch` | 6137 | Withheld fee not preserved correctly | +| `CompressAndCloseFrozenMismatch` | 6138 | Frozen state not preserved correctly | +| `DecompressDestinationMismatch` | 18057 | Destination/owner validation failed | +| `DecompressAmountMismatch` | 18064 | Amount mismatch for ATA decompress | +| `DecompressDelegatedAmountWithoutDelegate` | 18059 | delegated_amount > 0 but no delegate account | +| `DecompressWithheldFeeWithoutExtension` | 18060 | Withheld fee but no TransferFeeAccount extension | + +--- + +## Related Documentation + +- `docs/ctoken/CREATE.md` - Full ATA creation documentation +- `docs/compressed_token/TRANSFER2.md` - Transfer2 instruction including compress/decompress +- `docs/EXTENSIONS.md` - CompressedOnly extension behavior and validation +- `program-libs/compressible/docs/RENT.md` - Rent management for compressible accounts diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs index 025d3259ee..83449e92e4 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs @@ -180,8 +180,9 @@ fn validate_compressed_only_ext( return Err(ErrorCode::CompressAndCloseDelegatedAmountMismatch.into()); } - // 7b. Delegate pubkey must match (if present) + // 7b. Delegate pubkey must match (bidirectional check) if let Some(delegate) = ctoken.delegate() { + // CToken has delegate - output must have matching delegate if !compressed_token_account.has_delegate() { return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); } @@ -191,6 +192,9 @@ fn validate_compressed_only_ext( if !pubkey_eq(output_delegate, &delegate.to_bytes()) { return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); } + } else if compressed_token_account.has_delegate() { + // CToken has no delegate - output must not have delegate + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); } // 7c. Withheld fee must match diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs index 878c323883..5cd6adbb28 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -133,7 +133,11 @@ fn apply_delegate( .set_delegate(Some(Pubkey::from(*delegate_acc.key())))?; if delegated_amount > 0 { let current = ctoken.base.delegated_amount.get(); - ctoken.base.delegated_amount.set(current + delegated_amount); + ctoken.base.delegated_amount.set( + current + .checked_add(delegated_amount) + .ok_or(ProgramError::ArithmeticOverflow)?, + ); } } else if delegated_amount > 0 { msg!("Decompress: delegated_amount > 0 but no delegate"); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index 92699f2702..28cb5a6e8f 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -103,6 +103,13 @@ pub fn process_token_compression<'a>( )?; } SPL_TOKEN_ID => { + // CompressedOnly inputs must decompress to CToken accounts to preserve + // extension state (frozen, delegated, withheld fees). + if compression_to_input[compression_index].is_some() { + msg!("CompressedOnly inputs must decompress to CToken account"); + return Err(ErrorCode::CompressedOnlyRequiresCTokenDecompress.into()); + } + spl::process_spl_compressions( compression, &SPL_TOKEN_ID.to_pubkey_bytes(), @@ -115,9 +122,7 @@ pub fn process_token_compression<'a>( SPL_TOKEN_2022_ID => { // CompressedOnly inputs must decompress to CToken accounts to preserve // extension state (frozen, delegated, withheld fees). - if compression.mode.is_decompress() // TODO: double check that we need decompress check here. - && compression_to_input[compression_index].is_some() - { + if compression_to_input[compression_index].is_some() { msg!("CompressedOnly inputs must decompress to CToken account"); return Err(ErrorCode::CompressedOnlyRequiresCTokenDecompress.into()); } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs index 3d17754694..8e6b5c3d89 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs @@ -14,7 +14,7 @@ use pinocchio::{ }; use super::validate_compression_mode_fields; -use crate::constants::BUMP_CPI_AUTHORITY; +use crate::{constants::BUMP_CPI_AUTHORITY, shared::convert_pinocchio_token_error}; /// Process compression/decompression for SPL token accounts #[profile] @@ -168,11 +168,11 @@ fn spl_token_transfer_checked_common( match signers { Some(signers) => { pinocchio::cpi::slice_invoke_signed(&instruction, account_infos, signers) - .map_err(|_| ProgramError::InvalidArgument)?; + .map_err(convert_pinocchio_token_error)?; } None => { pinocchio::cpi::slice_invoke(&instruction, account_infos) - .map_err(|_| ProgramError::InvalidArgument)?; + .map_err(convert_pinocchio_token_error)?; } } From 15425e59bc8c8c86eb97a741f72120713ab02cc9 Mon Sep 17 00:00:00 2001 From: ananas Date: Sun, 11 Jan 2026 20:12:51 +0000 Subject: [PATCH 36/38] cleanup --- .../src/state/ctoken/zero_copy.rs | 1 - .../compressed-token/anchor/src/freeze.rs | 4 +- .../anchor/src/process_transfer.rs | 5 +- .../program/docs/ACCOUNT_CHECKS.md | 1154 ----------------- .../program/docs/COMPRESSIBLE_ATA.md | 458 ------- .../compressed_token/mint_action/accounts.rs | 12 +- .../program/tests/mint_action.rs | 13 +- 7 files changed, 19 insertions(+), 1628 deletions(-) delete mode 100644 programs/compressed-token/program/docs/ACCOUNT_CHECKS.md delete mode 100644 programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md diff --git a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs index 94bda53c62..f317a19074 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs @@ -162,7 +162,6 @@ impl<'a> ZeroCopyNew<'a> for CToken { remaining = write_remaining; (ACCOUNT_TYPE_TOKEN_ACCOUNT, Some(parsed_extensions)) } else { - // TODO: remaining bytes should be checked to be zero (ACCOUNT_TYPE_TOKEN_ACCOUNT, None) }; if !remaining.is_empty() { diff --git a/programs/compressed-token/anchor/src/freeze.rs b/programs/compressed-token/anchor/src/freeze.rs index 17a273d07e..be10899d25 100644 --- a/programs/compressed-token/anchor/src/freeze.rs +++ b/programs/compressed-token/anchor/src/freeze.rs @@ -122,7 +122,9 @@ pub fn create_input_and_output_accounts_freeze_or_thaw< } let (mut compressed_input_accounts, input_token_data, _) = - get_input_compressed_accounts_with_merkle_context_and_check_signer_for_freeze::( + get_input_compressed_accounts_with_merkle_context_and_check_signer_for_freeze::< + FROZEN_INPUTS, + >( // The signer in this case is the freeze authority. The owner is not // required to sign for this instruction. Hence, we pass the owner // from a variable instead of an account to still reproduce value diff --git a/programs/compressed-token/anchor/src/process_transfer.rs b/programs/compressed-token/anchor/src/process_transfer.rs index 0694ce4417..5980bda72a 100644 --- a/programs/compressed-token/anchor/src/process_transfer.rs +++ b/programs/compressed-token/anchor/src/process_transfer.rs @@ -599,7 +599,10 @@ pub struct CompressedTokenInstructionDataTransfer { pub with_transaction_hash: bool, } -fn get_input_compressed_accounts_with_merkle_context_and_check_signer_inner( +fn get_input_compressed_accounts_with_merkle_context_and_check_signer_inner< + const IS_FROZEN: bool, + const CHECK_TLV: bool, +>( signer: &Pubkey, signer_is_delegate: &Option, remaining_accounts: &[AccountInfo<'_>], diff --git a/programs/compressed-token/program/docs/ACCOUNT_CHECKS.md b/programs/compressed-token/program/docs/ACCOUNT_CHECKS.md deleted file mode 100644 index 5f20099a7c..0000000000 --- a/programs/compressed-token/program/docs/ACCOUNT_CHECKS.md +++ /dev/null @@ -1,1154 +0,0 @@ -# Account Checks Security Documentation - -This document provides comprehensive documentation of all Solana account validations in the compressed-token program. For each instruction, it details every account accessed, how it is validated, and what security checks are applied. - -## Table of Contents - -1. [Overview](#1-overview) -2. [Validation Framework Reference](#2-validation-framework-reference) -3. [Instructions by Category](#3-instructions-by-category) - - [3.1 Account Management](#31-account-management) - - [3.2 CToken Operations](#32-ctoken-operations) - - [3.3 Compressed Token Operations](#33-compressed-token-operations) - - [3.4 Compressible Rent Management](#34-compressible-rent-management) - - [3.5 Token Pool Operations (Anchor)](#35-token-pool-operations-anchor) -4. [Potential Gaps and Recommendations](#4-potential-gaps-and-recommendations) -5. [Quick Reference Tables](#5-quick-reference-tables) - ---- - -## 1. Overview - -### 1.1 Purpose and Scope - -This document covers account validation for **29 instructions** in the compressed-token program: -- **18 Pinocchio-based instructions** - Native Solana instructions with manual account parsing -- **11 Anchor-based instructions** - Instructions using Anchor framework constraints - -### 1.2 Validation Mechanisms Used - -| Mechanism | Description | Location | -|-----------|-------------|----------| -| `AccountIterator` | Sequential account parsing with named error locations | `program-libs/account-checks/src/account_iterator.rs` | -| `ProgramPackedAccounts` | Index-based dynamic account access | `program-libs/account-checks/src/packed_accounts.rs` | -| `check_signer()` | Verify account is transaction signer | `program-libs/account-checks/src/checks.rs:121` | -| `check_mut()` | Verify account is writable | `program-libs/account-checks/src/checks.rs:128` | -| `check_non_mut()` | Verify account is read-only | `program-libs/account-checks/src/checks.rs:43` | -| `check_owner()` | Verify account program ownership | `program-libs/account-checks/src/checks.rs:135` | -| `check_discriminator()` | Verify 8-byte account type prefix | `program-libs/account-checks/src/checks.rs:78` | -| `check_pda_seeds()` | Verify PDA derivation with find_program_address | `program-libs/account-checks/src/checks.rs:158` | -| `check_pda_seeds_with_bump()` | Verify PDA derivation with known bump | `program-libs/account-checks/src/checks.rs:170` | -| `verify_owner_or_delegate_signer()` | Token authority validation (owner/delegate/permanent_delegate) | `src/shared/owner_validation.rs:30` | -| `check_ctoken_owner()` | Compression authority validation | `src/shared/owner_validation.rs:83` | - -### 1.3 Error Code Ranges - -| Range | Source | Description | -|-------|--------|-------------| -| 20000-20015 | `AccountError` | Account validation errors from account-checks | -| 18001-18066 | `CTokenError` | Compressed token specific errors | -| 6000+ | `ErrorCode` | Anchor compressed token errors | - -**AccountError Codes (20000-20015):** - -| Code | Error | Description | -|------|-------|-------------| -| 20000 | `InvalidDiscriminator` | Account type prefix mismatch | -| 20001 | `AccountOwnedByWrongProgram` | Owner check failed | -| 20002 | `AccountNotMutable` | Mutability check failed | -| 20003 | `BorrowAccountDataFailed` | Cannot borrow account data | -| 20004 | `InvalidAccountSize` | Account size mismatch | -| 20005 | `AccountMutable` | Non-mutability check failed | -| 20006 | `AlreadyInitialized` | Account discriminator not zeroed | -| 20007 | `InvalidAccountBalance` | Insufficient lamports for rent | -| 20008 | `FailedBorrowRentSysvar` | Cannot read rent sysvar | -| 20009 | `InvalidSigner` | Signer check failed | -| 20010 | `InvalidSeeds` | PDA derivation mismatch | -| 20011 | `InvalidProgramId` | Program ID check failed | -| 20012 | `ProgramNotExecutable` | Program not executable | -| 20013 | `AccountNotZeroed` | Account not zeroed for init | -| 20014 | `NotEnoughAccountKeys` | Insufficient accounts provided | -| 20015 | `InvalidAccount` | Generic account validation failure | - ---- - -## 2. Validation Framework Reference - -### 2.1 AccountIterator Pattern - -Sequential account parsing with automatic validation and error location tracking. - -```rust -let mut iter = AccountIterator::new(account_infos); -let fee_payer = iter.next_signer_mut("fee_payer")?; // Checks: signer + mutable -let mint = iter.next_non_mut("mint")?; // Checks: non-mutable -let authority = iter.next_signer("authority")?; // Checks: signer only -let optional = iter.next_option_mut("opt", condition)?; // Conditional mutable -``` - -**Methods and Their Checks:** - -| Method | Signer | Mutable | Non-Mutable | -|--------|--------|---------|-------------| -| `next_account()` | - | - | - | -| `next_signer()` | Y | - | - | -| `next_mut()` | - | Y | - | -| `next_non_mut()` | - | - | Y | -| `next_signer_mut()` | Y | Y | - | -| `next_signer_non_mut()` | Y | - | Y | -| `next_option()` | - | - | - | -| `next_option_mut()` | - | Y | - | -| `next_option_signer()` | Y | - | - | - -### 2.2 Authority Validation Functions - -**`verify_owner_or_delegate_signer()`** - Token operations authorization: -``` -Location: src/shared/owner_validation.rs:30-78 -Accepts: -1. Owner account is signer -> OK -2. Delegate account is signer -> OK -3. Permanent delegate (from mint extension) is signer -> OK -Error: ErrorCode::OwnerMismatch (if none are signers) -``` - -**`check_ctoken_owner()`** - Compression operations authorization: -``` -Location: src/shared/owner_validation.rs:83-113 -Checks: -1. Authority account must be signer -> InvalidSigner -2. Authority matches owner -> OK -3. Authority matches permanent delegate -> OK -Error: ErrorCode::OwnerMismatch (if neither matches) -``` - -**`check_token_program_owner()`** - Token program ownership: -``` -Location: src/shared/owner_validation.rs:14-25 -Accepts: SPL Token | Token-2022 | CToken program -Error: ProgramError::IncorrectProgramId -``` - ---- - -## 3. Instructions by Category - -### 3.1 Account Management - -#### 3.1.1 CreateTokenAccount (Discriminator: 18) - -**Source:** `src/ctoken/create.rs:21-108` -**Enum:** `InstructionType::CreateTokenAccount` - -##### Account Layout - -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | token_account | Y | Conditional* | `next_signer_mut()` or `next_mut()` | 20002/20009 | -| 1 | mint | N | N | `next_non_mut()` | 20005 | -| 2** | payer | Y | Y | `next_signer_mut()` | 20002/20009 | -| 3** | config_account | N | N | `next_config_account()` | 20001/20000 | -| 4** | system_program | N | N | `next_non_mut()` | 20005 | -| 5** | rent_payer | Y | N | `next_mut()` | 20002 | - -*Conditional: Signer required only when `compressible_config` is Some -**Accounts 2-5 only required when `compressible_config` is Some - -##### Account Details - -**[0] token_account** -- **Mutability:** Always mutable -- **Signer:** Required for compressible accounts (PDA signer), not required for non-compressible -- **Owner:** For non-compressible, must already be owned by CToken program (implicit via write) -- **Validation Code:** - ```rust - // src/ctoken/create.rs:46-50 - let token_account = if is_compressible { - iter.next_signer_mut("token_account")? - } else { - iter.next_mut("token_account")? - }; - ``` - -**[1] mint** -- **Mutability:** Read-only -- **Owner:** SPL Token or Token-2022 (checked during extension parsing) -- **Validation:** Extensions parsed via `has_mint_extensions(mint)` - -**[3] config_account (if compressible)** -- **Owner:** Registry program `Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX` -- **Discriminator:** `CompressibleConfig::LIGHT_DISCRIMINATOR` -- **State:** Must be ACTIVE -- **Validation Code:** - ```rust - // src/shared/config_account.rs - check_owner(®istry_program_id, config_account)?; - check_discriminator::(data)?; - config.validate_active()?; - ``` - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | config via `check_owner()` | Mint extensions parsed after owner implicit via program | -| Discriminator | Y | `check_discriminator::()` | Config account type verified | -| Signer check | Y | `next_signer_mut()` for compressible | Token account signer for compressible path | -| PDA verification | Y | `check_seeds()` on compress_to_pubkey | If provided, verified against token_account | -| Frontrunning | Partial | Token account address deterministic if compressible | Non-compressible can be frontrun | - ---- - -#### 3.1.2 CreateAssociatedCTokenAccount (Discriminator: 100) - -**Source:** `src/ctoken/create_ata.rs:20-142` -**Enum:** `InstructionType::CreateAssociatedCTokenAccount` - -##### Account Layout - -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | owner | N | N | `next_account()` | - | -| 1 | mint | N | N | `next_account()` | - | -| 2 | fee_payer | Y | Y | `next_signer_mut()` | 20002/20009 | -| 3 | associated_token_account | Y | N | `next_mut()` | 20002 | -| 4 | system_program | N | N | `next_non_mut()` | 20005 | -| 5* | compressible_config | N | N | `next_config_account()` | 20001/20000 | -| 6* | rent_payer | Y | N | `next_mut()` | 20002 | - -*Accounts 5-6 only required when `compressible_config` is Some - -##### Account Details - -**[3] associated_token_account** -- **Owner Check:** Must be System program (uninitialized) at `create_ata.rs:75-77` - ```rust - if !associated_token_account.is_owned_by(&[0u8; 32]) { - return Err(ProgramError::IllegalOwner); - } - ``` -- **PDA Derivation:** Verified via seeds `[owner, ctoken_program, mint, bump]` - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | System program check | Must be uninitialized | -| PDA verification | Y | ATA seeds derivation | Address deterministic | -| Frontrunning | Y | PDA + owner check | Cannot frontrun with different settings | -| Account revival | N/A | New account creation | Not applicable | - ---- - -#### 3.1.3 CreateAssociatedTokenAccountIdempotent (Discriminator: 102) - -**Source:** `src/ctoken/create_ata.rs:29-34` -**Enum:** `InstructionType::CreateAssociatedTokenAccountIdempotent` - -Same as CreateAssociatedCTokenAccount with idempotent mode enabled. - -**Additional Check for Idempotent Mode:** -```rust -// src/ctoken/create_ata.rs:67-72 -if IDEMPOTENT { - validate_ata_derivation(associated_token_account, owner_bytes, mint_bytes, bump)?; - if associated_token_account.is_owned_by(&crate::LIGHT_CPI_SIGNER.program_id) { - return Ok(()); // Already exists, return early - } -} -``` - ---- - -#### 3.1.4 CloseTokenAccount (Discriminator: 9) - -**Source:** `src/ctoken/close/processor.rs:17-30` -**Accounts:** `src/ctoken/close/accounts.rs:8-33` -**Enum:** `InstructionType::CloseTokenAccount` - -##### Account Layout - -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | token_account | Y | N | `next_mut()` + `check_owner()` | 20001/20002 | -| 1 | destination | Y | N | `next_mut()` | 20002 | -| 2 | authority | N | Y | `next_signer()` | 20009 | -| 3* | rent_sponsor | Y | N | `next_mut()` | 20002 | - -*rent_sponsor required only if token_account has Compressible extension - -##### Account Details - -**[0] token_account** -- **Owner:** CToken program -- **Discriminator:** Implicitly checked via `CToken::from_account_info_mut_checked()` -- **Validation Code:** - ```rust - // src/ctoken/close/accounts.rs:20-21 - let token_account = iter.next_mut("token_account")?; - check_owner(&LIGHT_CPI_SIGNER.program_id, token_account)?; - ``` - -**[2] authority** -- **Authorization:** Must match close_authority (if set) OR owner -- **Signer Check:** `check_signer(accounts.authority)` at `close/processor.rs:111` -- **Validation Code:** - ```rust - // src/ctoken/close/processor.rs:78-98 - if let Some(close_authority) = ctoken.close_authority() { - if !pubkey_eq(ctoken.close_authority.array_ref(), accounts.authority.key()) { - return Err(ErrorCode::OwnerMismatch.into()); - } - } else { - if !pubkey_eq(ctoken.owner.array_ref(), accounts.authority.key()) { - return Err(ErrorCode::OwnerMismatch.into()); - } - } - ``` - -**[3] rent_sponsor (if compressible)** -- **Validation:** Must match `compression.info.rent_sponsor` stored in token account -- **Code:** - ```rust - // src/ctoken/close/processor.rs:60-63 - if compression.info.rent_sponsor != *rent_sponsor.key() { - return Err(ProgramError::InvalidAccountData); - } - ``` - -##### State Validations - -| Check | Location | Error | -|-------|----------|-------| -| Balance == 0 | `processor.rs:44-46` | `NonNativeHasBalance` | -| State != Frozen | `processor.rs:70-74` | `AccountFrozen` | -| State != Uninitialized | `processor.rs:73` | `UninitializedAccount` | -| Destination != token_account | `processor.rs:39-41` | `InvalidAccountData` | - -##### Account Closure Procedure (Tip 40 Compliance) - -```rust -// src/ctoken/close/processor.rs:197-204 -fn finalize_account_closure(accounts: &CloseTokenAccountAccounts<'_>) -> Result<(), ProgramError> { - unsafe { - accounts.token_account.assign(&[0u8; 32]); // Reassign to System program - } - accounts.token_account.resize(0)?; // Reallocate to 0 bytes - Ok(()) -} -``` - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | `check_owner()` | CToken program ownership verified | -| Discriminator | Y | `from_account_info_mut_checked()` | Implicit in zero-copy parse | -| Signer check | Y | `check_signer(authority)` | Authority must sign | -| Account revival | Y | `assign() + resize(0)` | Proper closure procedure | -| TOCTOU | Y | Balance check at close time | Balance verified before close | - ---- - -### 3.2 CToken Operations - -#### 3.2.1 CTokenTransfer (Discriminator: 3) - -**Source:** `src/ctoken/transfer/default.rs` -**Shared Logic:** `src/ctoken/transfer/shared.rs` -**Enum:** `InstructionType::CTokenTransfer` - -##### Account Layout - -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | source | Y | N | Via pinocchio_token_program | SPL errors | -| 1 | destination | Y | N | Via pinocchio_token_program | SPL errors | -| 2 | authority | N | Y | Via pinocchio_token_program + extension validation | SPL/20009 | -| 3* | payer | Y | Y | For top-up if needed | 18061 | - -*payer required if source or destination has Compressible extension needing top-up - -##### Delegation to pinocchio_token_program - -CTokenTransfer delegates core validation to `pinocchio_token_program::processor::transfer::process_transfer()` which performs: -- Source/destination owner check (CToken program) -- Source/destination mint match -- Source balance check -- Authority is owner OR delegate with sufficient delegated_amount -- Source not frozen - -##### Extension Validation (shared.rs) - -**Sender Validation:** -```rust -// src/ctoken/transfer/shared.rs:152-183 -let sender_info = process_account_extensions(source, &mut current_slot, mint)?; - -// For restricted extensions, mint is required -if sender_info.flags.has_restricted_extensions() { - let mint_account = transfer_accounts.mint.ok_or(ErrorCode::MintRequiredForTransfer)?; - Some(check_mint_extensions(mint_account, deny_restricted_extensions)?) -} -``` - -**Permanent Delegate Validation:** -```rust -// src/ctoken/transfer/shared.rs:197-214 -fn validate_permanent_delegate(mint_checks: Option<&MintExtensionChecks>, authority: &AccountInfo) -> Result { - // If permanent_delegate matches authority and is signer -> skip pinocchio validation - if !authority.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - Ok(true) -} -``` - -**T22 Extension Consistency:** -```rust -// src/ctoken/transfer/shared.rs:32-43 -fn check_t22_extensions(&self, other: &Self) -> Result<(), ProgramError> { - if self.flags.has_pausable != other.flags.has_pausable - || self.flags.has_permanent_delegate != other.flags.has_permanent_delegate - || ... { - Err(ProgramError::InvalidInstructionData) - } -} -``` - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | pinocchio + `from_account_info_mut_checked()` | CToken ownership via pinocchio | -| Signer check | Y | pinocchio + `validate_permanent_delegate()` | Multiple auth paths | -| TOCTOU | Y | Amount in instruction data | Fixed amount at call time | -| Duplicate accounts | Y | pinocchio handles | Same-account transfer checked | - ---- - -#### 3.2.2 CTokenTransferChecked (Discriminator: 12) - -**Source:** `src/ctoken/transfer/checked.rs` -**Enum:** `InstructionType::CTokenTransferChecked` - -Same as CTokenTransfer with additional: -- Mint account required (not optional) -- Decimals validation via pinocchio_token_program -- Restricted extensions ALLOWED (unlike CTokenTransfer which blocks them) - ---- - -#### 3.2.3 CTokenApprove (Discriminator: 4) - -**Source:** `src/ctoken/approve_revoke.rs:28-41` -**Enum:** `InstructionType::CTokenApprove` - -##### Account Layout - -| Index | Account | Checks | Notes | -|-------|---------|--------|-------| -| 0 | source | Via pinocchio | Token account to approve | -| 1 | delegate | Via pinocchio | Account receiving delegation | -| 2* | payer | Signer + mutable | For compressible top-up | - -##### Delegation to pinocchio_token_program - -```rust -// src/ctoken/approve_revoke.rs:38-39 -process_approve(accounts, &instruction_data[..APPROVE_BASE_LEN]) - .map_err(convert_pinocchio_token_error)?; -``` - -pinocchio_token_program validates: -- Source account owned by CToken program -- Authority matches source.owner -- Authority is signer -- Source not frozen - ---- - -#### 3.2.4 CTokenRevoke (Discriminator: 5) - -**Source:** `src/ctoken/approve_revoke.rs:50-59` -**Enum:** `InstructionType::CTokenRevoke` - -##### Account Layout - -| Index | Account | Checks | Notes | -|-------|---------|--------|-------| -| 0 | source | Via pinocchio | Token account to revoke | -| 1* | payer | Signer + mutable | For compressible top-up | - -##### Delegation to pinocchio_token_program - -```rust -process_revoke(accounts).map_err(convert_pinocchio_token_error)?; -``` - ---- - -#### 3.2.5 CTokenMintTo (Discriminator: 7) - -**Source:** `src/ctoken/mint_to.rs` -**Enum:** `InstructionType::CTokenMintTo` - -Delegates to pinocchio_token_program::processor::mint_to::process_mint_to() - -Validation: -- Mint owned by Token program -- Authority matches mint.mint_authority -- Authority is signer -- Destination owned by CToken program -- Destination.mint matches mint - ---- - -#### 3.2.6 CTokenMintToChecked (Discriminator: 14) - -Same as CTokenMintTo with decimals validation. - ---- - -#### 3.2.7 CTokenBurn (Discriminator: 8) - -**Source:** `src/ctoken/burn.rs` -**Enum:** `InstructionType::CTokenBurn` - -Delegates to pinocchio_token_program::processor::burn::process_burn() - -Validation: -- Source owned by CToken program -- Authority matches source.owner OR source.delegate -- Authority is signer -- Source not frozen -- Sufficient balance/delegated_amount - ---- - -#### 3.2.8 CTokenBurnChecked (Discriminator: 15) - -Same as CTokenBurn with decimals validation. - ---- - -#### 3.2.9 CTokenFreezeAccount (Discriminator: 10) - -**Source:** `src/ctoken/freeze_thaw.rs` -**Enum:** `InstructionType::CTokenFreezeAccount` - -Delegates to pinocchio_token_program::processor::freeze_account::process_freeze_account() - -Validation: -- Account owned by CToken program -- Mint owned by Token program -- Authority matches mint.freeze_authority -- Authority is signer -- Account.mint matches mint - ---- - -#### 3.2.10 CTokenThawAccount (Discriminator: 11) - -**Source:** `src/ctoken/freeze_thaw.rs` -**Enum:** `InstructionType::CTokenThawAccount` - -Delegates to pinocchio_token_program::processor::thaw_account::process_thaw_account() - -Same validation as FreezeAccount. - ---- - -### 3.3 Compressed Token Operations - -#### 3.3.1 Transfer2 (Discriminator: 101) - -**Source:** `src/compressed_token/transfer2/processor.rs` -**Accounts:** `src/compressed_token/transfer2/accounts.rs` -**Enum:** `InstructionType::Transfer2` - -This is the most complex instruction supporting: -- Compressed-to-compressed transfers -- Compress (SPL/CToken -> compressed) -- Decompress (compressed -> CToken) -- CompressAndClose (CToken -> compressed + close) - -##### Account Layout (varies by mode) - -**Mode 1: No compressed accounts (compressions only)** -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | cpi_authority_pda | N | N | `next_account()` | - | -| 1 | fee_payer | N | Y | `next_signer()` | 20009 | -| 2+ | packed_accounts | - | - | `remaining()` | - | - -**Mode 2: With CPI context write** -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | light_system_program | N | N | `next_non_mut()` | 20005 | -| 1+ | CpiContextLightSystemAccounts | - | - | Various | - | -| N+ | packed_accounts | - | - | `remaining()` | - | - -**Mode 3: Standard execution** -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | light_system_program | N | N | `next_non_mut()` | 20005 | -| 1+ | LightSystemAccounts | - | - | Various | - | -| N+ | packed_accounts | - | - | `remaining()` | - | - -##### LightSystemAccounts Validation (`src/shared/accounts.rs`) - -| Field | Check | Error | -|-------|-------|-------| -| fee_payer | `next_signer_mut()` | 20002/20009 | -| cpi_authority_pda | `next_non_mut()` | 20005 | -| registered_program_pda | `next_non_mut()` | 20005 | -| account_compression_authority | `next_non_mut()` | 20005 | -| account_compression_program | `next_non_mut()` | 20005 | -| system_program | `next_non_mut()` | 20005 | -| sol_pool_pda (optional) | `next_option()` | - | -| sol_decompression_recipient (optional) | `next_option()` | - | -| cpi_context (optional) | `next_option_mut()` | - | - -##### Input Token Data Validation (`src/shared/token_input.rs:30-159`) - -```rust -// Index-based account retrieval from packed_accounts -let owner_account = packed_accounts.get(input_token_data.owner as usize)?; -let delegate_account = packed_accounts.get(input_token_data.delegate as usize)?; -let mint_account = packed_accounts.get(input_token_data.mint as usize)?; - -// ATA derivation check for is_ata=true (lines 191-238) -if data.is_ata() { - let wallet_owner = packed_accounts.get(data.owner_index as usize)?; - let derived_ata = create_program_address(&ata_seeds, &program_id)?; - if !pubkey_eq(owner_account.key(), &derived_ata) { - return Err(CTokenError::InvalidAtaDerivation.into()); - } -} - -// Authority validation (lines 89-94) -verify_owner_or_delegate_signer(signer_account, delegate_account, permanent_delegate, all_accounts)?; -``` - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | token_input.rs via packed_accounts | Index-based, bounds-checked | -| PDA verification | Y | resolve_ata_signer() | ATA derivation verified | -| Signer check | Y | verify_owner_or_delegate_signer() | Multi-auth supported | -| TOCTOU | Y | Amount in instruction data | Fixed at call time | -| Duplicate accounts | N/A | Light system handles | Via CPI | - ---- - -#### 3.3.2 MintAction (Discriminator: 103) - -**Source:** `src/compressed_token/mint_action/processor.rs` -**Accounts:** `src/compressed_token/mint_action/accounts.rs` -**Enum:** `InstructionType::MintAction` - -Supports 10 action types: -1. CreateCompressedMint -2. MintToCompressed -3. MintToCToken -4. UpdateMintAuthority -5. UpdateFreezeAuthority -6. UpdateMetadataField -7. UpdateMetadataAuthority -8. RemoveMetadataKey -9. DecompressMint -10. CompressAndCloseCMint - -##### Account Layout (varies by action) - -| Index | Account | Mut | Signer | Condition | Checks | -|-------|---------|-----|--------|-----------|--------| -| 0 | light_system_program | N | N | Always | `next_account()` | -| 1 | mint_signer | N | Conditional* | `with_mint_signer` | `next_option_signer()` or `next_option()` | -| 2 | authority | N | Y | Always | `next_signer()` | -| 3 | compressible_config | N | N | `needs_compressible_accounts()` | `next_config_account()` | -| 4 | cmint | Y | N | `needs_cmint_account()` | `next_option_mut()` | -| 5 | rent_sponsor | Y | N | `needs_compressible_accounts()` | `next_option_mut()` | -| 6+ | LightSystemAccounts | - | - | Not write_to_cpi_context | Various | -| N | out_output_queue | N | N | Not write_to_cpi_context | `next_account()` | -| N+1 | address_merkle_tree OR in_merkle_tree | N | N | Depends on `create_mint` | `next_account()` | -| N+2 | in_output_queue | N | N | Not `create_mint` | `next_option()` | -| N+3 | tokens_out_queue | N | N | `has_mint_to_actions` | `next_option()` | -| N+4+ | packed_accounts (tree + recipient accounts) | - | - | - | `remaining_unchecked()` | - -*mint_signer must sign only for CreateCompressedMint, not for DecompressMint - -##### Key Validations (`src/compressed_token/mint_action/accounts.rs`) - -**mint_signer:** -```rust -// Line 79-83: Signer required for create_mint only -let mint_signer = if config.mint_signer_must_sign() { - iter.next_option_signer("mint_signer", config.with_mint_signer)? -} else { - iter.next_option("mint_signer", config.with_mint_signer)? -}; -``` - -**authority:** -```rust -// Line 86: Always required to sign -let authority = iter.next_signer("authority")?; -``` - -**Address Merkle Tree Validation:** -```rust -// Line 325-333: Must match expected CMINT_ADDRESS_TREE -if let Some(address_tree) = accounts.address_merkle_tree { - if *address_tree.key() != CMINT_ADDRESS_TREE { - return Err(ErrorCode::InvalidAddressTree.into()); - } -} -``` - -**CMint Account Match:** -```rust -// Line 318-322: Verify CMint matches expected pubkey -if let (Some(cmint_account), Some(expected_pubkey)) = (accounts.cmint, cmint_pubkey) { - if expected_pubkey.to_bytes() != *cmint_account.key() { - return Err(ErrorCode::MintAccountMismatch.into()); - } -} -``` - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | CMint via `zero_copy_at_mut_checked()` | Owner implicit via parse | -| Discriminator | Y | Zero-copy parsing | CMint discriminator checked | -| Signer check | Y | `next_signer()` for authority | Always required | -| PDA verification | Y | `CMINT_ADDRESS_TREE` constant | Fixed address tree | -| CPI program check | Y | Light system hardcoded | Via constant | - ---- - -### 3.4 Compressible Rent Management - -#### 3.4.1 Claim (Discriminator: 104) - -**Source:** `src/compressible/claim.rs` -**Enum:** `InstructionType::Claim` - -##### Account Layout - -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | rent_sponsor | Y | N | `next_mut()` | 20002 | -| 1 | compression_authority | N | Y | `next_signer()` | 20009 | -| 2 | config_account | N | N | `parse_config_account()` | 20001/20000 | -| 3+ | token_accounts | Y | N | `check_owner()` per account | 20001 | - -##### Account Details - -**[0] rent_sponsor** -- Must match `config_account.rent_sponsor` -- Validation at `claim.rs:45-48` - -**[1] compression_authority** -- Must be signer -- Must match `config_account.compression_authority` -- Validation at `claim.rs:41-44` - -**[2] config_account** -- Owner: Registry program `Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX` -- Discriminator: `CompressibleConfig::LIGHT_DISCRIMINATOR` -- State: Not INACTIVE (`validate_not_inactive()` at line 37-39) - -**[3+] token_accounts (variable)** -- Each validated by `check_owner(&LIGHT_CPI_SIGNER.program_id, account)` at line 114 -- Must have Compressible or CMint extension with matching rent_sponsor -- Account type determined by size: 165 bytes = CToken, >165 bytes = check byte 165 - -##### State Validations (`claim.rs:107-160`) - -```rust -// Account type determination (lines 97-105) -fn determine_account_type(data: &[u8]) -> Result { - match data.len().cmp(&165) { - Less => Err(InvalidAccountData), - Equal => Ok(ACCOUNT_TYPE_TOKEN_ACCOUNT), - Greater => Ok(data[165]) - } -} - -// For CToken accounts -let compressible = ctoken.get_compressible_extension_mut() - .ok_or(CTokenError::MissingCompressibleExtension)?; - -// For CMint accounts -cmint.base.compression.claim_and_update(claim_and_update)?; -``` - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | `check_owner()` per token account | Line 114 | -| Discriminator | Y | `zero_copy_at_mut_checked()` | Implicit via parse | -| Signer check | Y | `next_signer()` | compression_authority | -| Authority match | Y | `compression_authority == config.compression_authority` | Line 41-44 | -| Rent sponsor match | Y | `rent_sponsor == config.rent_sponsor` | Line 45-48 | - ---- - -#### 3.4.2 WithdrawFundingPool (Discriminator: 105) - -**Source:** `src/compressible/withdraw_funding_pool.rs` -**Enum:** `InstructionType::WithdrawFundingPool` - -##### Account Layout - -| Index | Account | Mut | Signer | Checks | Error | -|-------|---------|-----|--------|--------|-------| -| 0 | rent_sponsor | Y | N | `next_mut()` + config match | 20002 | -| 1 | compression_authority | N | Y | `next_signer()` + config match | 20009 | -| 2 | destination | Y | N | `next_mut()` | 20002 | -| 3 | system_program | N | N | `next_account()` | - | -| 4 | config | N | N | `parse_config_account()` | 20001/20000 | - -##### Account Details - -**[0] rent_sponsor** -- Must match `config_account.rent_sponsor` -- PDA derived with seeds `[b"rent_sponsor", version_bytes, bump]` -- Used for `invoke_signed` transfer - -**[1] compression_authority** -- Must be signer -- Must match `config_account.compression_authority` -- Validation at line 46-49 - -**[4] config** -- Owner: Registry program -- Discriminator: CompressibleConfig -- State: Not INACTIVE - -##### CPI Verification (Tip 27 Compliance) - -```rust -// Lines 110-118: System program transfer via invoke_signed -let transfer = Transfer { - from: accounts.rent_sponsor, - to: accounts.destination, - lamports: amount, -}; -transfer.invoke_signed(&[signer]).map_err(convert_program_error) -``` - -The system program is passed as an account but the `pinocchio_system::Transfer` instruction uses the hardcoded system program ID internally, so CPI program verification is implicit. - -##### Security Analysis - -| Attack Pattern | Protected | Check Location | Notes | -|----------------|-----------|----------------|-------| -| Owner before read | Y | `parse_config_account()` checks owner | Config account | -| Signer check | Y | `next_signer()` | compression_authority | -| Authority match | Y | config.compression_authority comparison | Line 46-49 | -| Rent sponsor match | Y | config.rent_sponsor comparison | Line 50-53 | -| CPI program check | Y | pinocchio_system hardcoded | Implicit | -| Balance check | Y | `pool_lamports < amount` | Line 91-99 | - ---- - -### 3.5 Token Pool Operations (Anchor) - -The following instructions use Anchor framework and are defined in `programs/compressed-token/anchor/src/lib.rs`. - -Anchor provides automatic validation through the `#[derive(Accounts)]` macro: -- `#[account(mut)]` - Mutability check -- `#[account(signer)]` - Signer check -- `#[account(owner = X)]` - Owner check -- `#[account(constraint = X)]` - Custom constraint -- `has_one = X` - Field match check -- `seeds = [...]` - PDA derivation check -- `init` - Initialize new account with size/owner - -**Note:** Anchor instructions primarily operate on compressed accounts via CPI to the Light System Program. The account validation for compressed account operations is handled by the light-system-program. - -#### 3.5.1 CreateTokenPool - -**Source:** `programs/compressed-token/anchor/src/lib.rs:50-63` - -Creates a token pool for SPL token compression. Each SPL mint can have one primary token pool. - -**Accounts:** `CreateTokenPoolInstruction` -- `fee_payer` - Signer, mutable (pays for account) -- `token_pool_pda` - Mutable, initialized via PDA `[b"pool", mint]` -- `mint` - SPL Token/Token-2022 mint -- `system_program` - System program -- `token_program` - SPL Token or Token-2022 program -- `cpi_authority_pda` - PDA authority for the pool - -**Validation:** -- Mint extensions checked via `assert_mint_extensions()` -- Token account initialized via CPI to token program - -#### 3.5.2 AddTokenPool - -**Source:** `programs/compressed-token/anchor/src/lib.rs:68-95` - -Creates additional token pools (max 5 per mint). - -**Accounts:** `AddTokenPoolInstruction` -- `fee_payer` - Signer, mutable -- `token_pool_pda` - New pool PDA with index -- `existing_token_pool_pda` - Previous pool (must exist) -- `mint` - SPL Token/Token-2022 mint -- `system_program` - System program -- `token_program` - SPL Token or Token-2022 program -- `cpi_authority_pda` - PDA authority - -**Validation:** -- `token_pool_index >= NUM_MAX_POOL_ACCOUNTS` (5) -> `InvalidTokenPoolBump` -- Previous pool PDA validated via `is_valid_spl_interface_pda()` - -#### 3.5.3 MintTo (Anchor) - -**Source:** `programs/compressed-token/anchor/src/lib.rs:104-118` - -Mints SPL tokens to compressed accounts. - -**Accounts:** `MintToInstruction` -- `fee_payer` - Signer, mutable -- `authority` - Mint authority signer -- `mint` - SPL mint (has_one = authority) -- `token_pool_pda` - Token pool -- `token_program` - Token program -- Remaining accounts for Light System CPI - -**Validation:** -- Authority must match mint.mint_authority (Anchor `has_one`) -- Tokens transferred to pool, compressed equivalents created - -#### 3.5.4 BatchCompress - -**Source:** `programs/compressed-token/anchor/src/lib.rs:121-146` - -Batch compress tokens to multiple recipients. - -**Accounts:** Same as `MintToInstruction` - -**Validation:** -- Cannot have both `amounts` and `amount` in instruction data -> `AmountsAndAmountProvided` -- Must have either `amounts` or `amount` -> `NoAmount` - -#### 3.5.5 CompressSplTokenAccount - -**Source:** `programs/compressed-token/anchor/src/lib.rs:151-158` - -Compresses SPL token account balance to compressed tokens. - -**Accounts:** `TransferInstruction` -- `fee_payer` - Signer, mutable -- `authority` - Token account authority signer -- `compress_or_decompress_token_account` - SPL token account -- `token_pool_pda` - Token pool -- `token_program` - Token program -- Remaining accounts for Light System CPI - -**Validation:** -- Authority must be owner of compress_or_decompress_token_account -- Sufficient balance for compression - -#### 3.5.6 Transfer (Anchor) - -**Source:** `programs/compressed-token/anchor/src/lib.rs:168-182` - -Transfers compressed tokens with optional compression/decompression. - -**Accounts:** `TransferInstruction` - -**Validation:** -- CPI context validated if compression/decompression (`check_cpi_context()`) -- Sum checks performed (inputs = outputs + compression/decompression) - -#### 3.5.7 Approve (Anchor) - -**Source:** `programs/compressed-token/anchor/src/lib.rs:190-195` - -Delegates compressed tokens. - -**Accounts:** `GenericInstruction` -- Standard Light System accounts for compressed account operations - -**Validation:** -- Owner must sign (via compressed account proof) -- Creates delegated output + change output - -#### 3.5.8 Revoke (Anchor) - -**Source:** `programs/compressed-token/anchor/src/lib.rs:199-204` - -Revokes delegation on compressed tokens. - -**Accounts:** `GenericInstruction` - -**Validation:** -- Owner must sign (not delegate) -- Merges all inputs into single undelegated output - -#### 3.5.9 Freeze (Anchor) - -**Source:** `programs/compressed-token/anchor/src/lib.rs:208-214` - -Freezes compressed token accounts. - -**Accounts:** `FreezeInstruction` -- `authority` - Freeze authority signer -- `mint` - SPL mint with freeze authority -- Remaining accounts for Light System CPI - -**Validation:** -- Input accounts must NOT be frozen -- Authority must match mint.freeze_authority - -#### 3.5.10 Thaw (Anchor) - -**Source:** `programs/compressed-token/anchor/src/lib.rs:218-224` - -Thaws frozen compressed token accounts. - -**Accounts:** `FreezeInstruction` - -**Validation:** -- Input accounts must BE frozen -- Authority must match mint.freeze_authority - -#### 3.5.11 Burn (Anchor) - -**Source:** `programs/compressed-token/anchor/src/lib.rs:229-234` - -Burns compressed tokens. - -**Accounts:** `BurnInstruction` -- `authority` - Owner or delegate signer -- `mint` - SPL mint -- `token_pool_pda` - Token pool (for SPL token burn) -- `token_program` - Token program -- Remaining accounts for Light System CPI - -**Validation:** -- Delegates can burn (output remains delegated) -- SPL tokens burned from pool account - ---- - -## 4. Potential Gaps and Recommendations - -During this security review, the following areas were identified for potential hardening: - -### 4.1 Packed Accounts Owner Validation - -**Location:** `src/shared/token_input.rs`, `src/compressed_token/transfer2/accounts.rs` - -**Observation:** Accounts retrieved from `packed_accounts` by index (owner, delegate, mint) do not have explicit `check_owner()` calls at retrieval time. Validation relies on zero-copy parsing to fail if data is invalid. - -**Current Protection:** Implicit via CToken/CMint deserialization checks. - -**Recommendation:** Consider adding explicit owner checks for mint accounts before parsing extensions. - -### 4.2 Tree Account Identification Heuristic - -**Location:** `src/compressed_token/transfer2/accounts.rs:147-155` - -```rust -// Checks first 8 bytes of owner (account-compression program prefix) -if account_info.owner()[0..8] == [9, 44, 54, 236, 34, 245, 23, 131] { -``` - -**Status:** Acceptable - the 8-byte prefix of the account-compression program ID is unique and a collision is practically impossible. This optimization reduces compute cost without sacrificing security. - -### 4.3 Non-Compressible CreateTokenAccount - -**Location:** `src/ctoken/create.rs:49` - -**Observation:** In non-compressible path, `token_account` fetched via `next_mut()` without explicit owner check. Comment states "ownership is implicitly validated when writing." - -**Current Protection:** Solana runtime enforces owner-only writes. - -**Status:** Acceptable - runtime protection adequate. - -### 4.4 Areas with Strong Protection - -The following areas have robust account validation: - -1. **CloseTokenAccount** - Proper `check_owner()`, `check_signer()`, and account revival prevention (`assign()` + `resize(0)`) - -2. **Config Account Validation** - Full owner + discriminator + state validation via `parse_config_account()` - -3. **Authority Validation** - `verify_owner_or_delegate_signer()` covers owner, delegate, and permanent delegate with proper signer checks - -4. **ATA Derivation** - `resolve_ata_signer()` in token_input.rs properly validates PDA derivation - -5. **CPI Safety** - All CPI calls use hardcoded program IDs (light_system_program, pinocchio_system, pinocchio_token_program) - ---- - -## 5. Quick Reference Tables - -### 5.1 Account Checks by Instruction - -| Instruction | Disc | Accounts | Signers | Owner Checks | PDA Checks | -|-------------|------|----------|---------|--------------|------------| -| CreateTokenAccount | 18 | 2-6 | 1-2 | config | compress_to_pubkey | -| CreateAssociatedCTokenAccount | 100 | 5-7 | 1 | system, config | ATA derivation | -| CreateAssociatedTokenAccountIdempotent | 102 | 5-7 | 1 | system/ctoken, config | ATA derivation | -| CloseTokenAccount | 9 | 3-4 | 1 | ctoken | - | -| CTokenTransfer | 3 | 3-4 | 1 | ctoken (via pinocchio) | - | -| CTokenTransferChecked | 12 | 4-5 | 1 | ctoken, token (via pinocchio) | - | -| CTokenApprove | 4 | 2-3 | 1 | ctoken (via pinocchio) | - | -| CTokenRevoke | 5 | 1-2 | 1 | ctoken (via pinocchio) | - | -| CTokenMintTo | 7 | 3-4 | 1 | token, ctoken (via pinocchio) | - | -| CTokenMintToChecked | 14 | 3-4 | 1 | token, ctoken (via pinocchio) | - | -| CTokenBurn | 8 | 3-4 | 1 | token, ctoken (via pinocchio) | - | -| CTokenBurnChecked | 15 | 3-4 | 1 | token, ctoken (via pinocchio) | - | -| CTokenFreezeAccount | 10 | 3 | 1 | token, ctoken (via pinocchio) | - | -| CTokenThawAccount | 11 | 3 | 1 | token, ctoken (via pinocchio) | - | -| Transfer2 | 101 | Variable | 1+ | ctoken, light_system | Various | -| MintAction | 103 | Variable | 1+ | ctoken, light_system | Various | -| Claim | 104 | 3+ | 1 | ctoken, registry | - | -| WithdrawFundingPool | 105 | 5 | 1 | registry | rent_sponsor PDA | - -### 5.2 Security Checklist Summary - -| Attack Pattern | Protection Method | Key Locations | -|----------------|-------------------|---------------| -| Owner before read | `check_owner()`, pinocchio validation | All instruction entry points | -| Discriminator | `check_discriminator()`, zero-copy parse | Account deserialization | -| Signer verification | `check_signer()`, `next_signer*()` | Authority accounts | -| PDA verification | `check_pda_seeds()`, `validate_ata_derivation()` | ATA, rent_sponsor | -| Account revival | `assign()` + `resize(0)` | CloseTokenAccount | -| TOCTOU | Amount in instruction data | Transfer, Burn, Approve | -| CPI program check | pinocchio_token_program hardcoded | All CPI calls | -| Duplicate accounts | pinocchio handles | Transfer operations | - ---- - -## Appendix - -### A. File Reference - -**Validation Primitives:** -- `program-libs/account-checks/src/checks.rs` -- `program-libs/account-checks/src/account_iterator.rs` -- `program-libs/account-checks/src/error.rs` - -**Token-Specific Validation:** -- `programs/compressed-token/program/src/shared/owner_validation.rs` -- `programs/compressed-token/program/src/shared/token_input.rs` -- `programs/compressed-token/program/src/shared/config_account.rs` -- `programs/compressed-token/program/src/extensions/check_mint_extensions.rs` - -**Instruction Processors:** -- `programs/compressed-token/program/src/ctoken/` - CToken operations -- `programs/compressed-token/program/src/compressed_token/` - Compressed operations -- `programs/compressed-token/program/src/compressible/` - Rent management -- `programs/compressed-token/anchor/src/lib.rs` - Anchor instructions diff --git a/programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md b/programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md deleted file mode 100644 index 0e5a8c360a..0000000000 --- a/programs/compressed-token/program/docs/COMPRESSIBLE_ATA.md +++ /dev/null @@ -1,458 +0,0 @@ -# Compressible ATA Lifecycle - -This document describes the complete lifecycle of a compressible Associated Token Account (ATA) from creation through compress-and-close to decompression. - -## Overview - -A **compressible ATA** is an Associated Token Account with the Compressible extension enabled. Unlike regular ATAs or regular CToken accounts, compressible ATAs support rent management and can be compressed to save on-chain storage costs. - -**Key characteristics:** -- PDA derived from `[wallet_owner, ctoken_program_id, mint, bump]` -- Always has `compression_only` flag set (required) -- Cannot use `compress_to_pubkey` (ATAs always compress to owner) -- Has `is_ata=1` flag in Compressible extension -- Supports full state preservation during compress/decompress cycles - -**Why `is_ata` matters:** -ATAs are PDAs and cannot sign transactions. During compression, the ATA pubkey becomes the "owner" of the compressed token account. The `is_ata` flag and associated `owner_index`/`bump` fields allow the system to: -1. Validate the original wallet owner during decompress -2. Verify ATA derivation to prevent spoofing -3. Route signing authority to the wallet owner instead of the ATA - ---- - -## Data Structures - -### CompressibleExtension (on CToken account) - -**Path:** `program-libs/ctoken-interface/src/state/extensions/compressible.rs` - -```rust -pub struct CompressibleExtension { - pub decimals_option: u8, // Whether decimals are cached - pub decimals: u8, // Cached decimals value - pub compression_only: bool, // Must be true for ATAs - pub is_ata: u8, // 1=ATA, 0=regular account - pub info: CompressionInfo, // Rent management data -} -``` - -The `is_ata` field is set to `1` during ATA creation and is used during CompressAndClose to determine owner validation behavior. - -### CompressedOnlyExtension (on compressed account TLV) - -**Path:** `program-libs/ctoken-interface/src/state/extensions/compressed_only.rs` - -```rust -pub struct CompressedOnlyExtension { - pub delegated_amount: u64, // Preserved delegated amount - pub withheld_transfer_fee: u64, // Preserved withheld fees - pub is_ata: u8, // ATA flag (1 or 0) -} -``` - -This extension is stored in the compressed token account's TLV data and preserves account state during compression. - -### CompressedOnlyExtensionInstructionData (passed in instruction) - -**Path:** `program-libs/ctoken-interface/src/instructions/extensions/compressed_only.rs` - -```rust -pub struct CompressedOnlyExtensionInstructionData { - pub delegated_amount: u64, - pub withheld_transfer_fee: u64, - pub is_frozen: bool, // Whether source was frozen - pub compression_index: u8, // Index of compression operation - pub is_ata: bool, // ATA flag - pub bump: u8, // ATA PDA derivation bump - pub owner_index: u8, // Index to wallet owner in packed accounts -} -``` - -The `bump` and `owner_index` fields are only used when `is_ata=true` to verify ATA derivation during decompress. - ---- - -## 1. ATA Creation - -**Path:** `programs/compressed-token/program/src/ctoken/create_ata.rs` - -### Instruction: CreateAssociatedCTokenAccount (100) / CreateAssociatedTokenAccountIdempotent (102) - -### Accounts -1. associated_token_account (mutable) - The ATA to create -2. fee_payer (signer, mutable) - Pays transaction fees -3. owner - Wallet owner of the ATA -4. mint - Token mint account -5. system_program -6. compressible_config (optional) - Required for compressible ATAs -7. rent_payer (optional) - Custom rent payer or uses config.rent_sponsor - -### Validation Checks - -| Check | Error | -|-------|-------| -| Account owned by system program (uninitialized) | `IllegalOwner` | -| `compress_to_account_pubkey` is None | `InvalidInstructionData` | -| `compression_only != 0` | `AtaRequiresCompressionOnly` | -| CompressibleConfig is ACTIVE | `InvalidState` | -| `rent_payment != 1` epoch | `OneEpochPrefundingNotAllowed` | -| PDA derivation correct (idempotent mode) | Validated by `validate_ata_derivation` | - -### Creation Flow - -1. **Derive ATA address:** - ```rust - let (ata_pubkey, bump) = Pubkey::find_program_address( - &[owner.key(), CTOKEN_PROGRAM_ID.as_ref(), mint.key()], - &CTOKEN_PROGRAM_ID - ); - ``` - -2. **Validate compressible requirements:** - - `compression_only` must be non-zero - - `compress_to_account_pubkey` must be None (ATAs compress to owner automatically) - -3. **Calculate account size:** Includes Compressible extension and any mint extension markers - -4. **Calculate rent:** rent_exemption + prepaid_epochs_rent + compression_incentive - -5. **Create account:** Via CPI with rent_sponsor PDA or custom rent payer - -6. **Initialize CToken:** Sets `is_ata=1` in Compressible extension - -**Path:** `programs/compressed-token/program/src/shared/initialize_ctoken_account.rs` (line 300) -```rust -compressible_ext.is_ata = is_ata as u8; // Set from create_ata parameter -``` - ---- - -## 2. CompressAndClose - -**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_and_close.rs` - -### Instruction: Transfer2 (101) with CompressionMode::CompressAndClose - -CompressAndClose closes a CToken account and creates a compressed token account. For ATAs, this requires special handling because the ATA pubkey becomes the owner of the compressed account. - -### Owner Validation (lines 103-115) - -For ATAs, the expected owner is the **ATA pubkey** (not the wallet owner): - -```rust -let expected_owner = if compression.info.compress_to_pubkey() || compression.is_ata() { - token_account_pubkey // ATA pubkey is the owner -} else { - &ctoken.owner.to_bytes() -}; -if output_owner != expected_owner { - return Err(ErrorCode::CompressAndCloseInvalidOwner.into()); -} -``` - -### CompressedOnly Extension Requirement (lines 136-145) - -ATAs **require** the CompressedOnly extension in output TLV: - -```rust -if (compression.compression_only() || compression.is_ata()) && compression_only_ext.is_none() { - return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); -} -``` - -### Data Preservation Validation (lines 170-222) - -The `validate_compressed_only_ext` function validates all preserved data: - -| Field | Validation | Error | -|-------|------------|-------| -| `delegated_amount` | Must match CToken's delegated_amount | `CompressAndCloseDelegatedAmountMismatch` (6135) | -| `delegate` | Must match if delegated_amount > 0 | `CompressAndCloseInvalidDelegate` (6136) | -| `withheld_transfer_fee` | Must match TransferFeeAccount withheld | `CompressAndCloseWithheldFeeMismatch` (6137) | -| `is_frozen` | Must match CToken state (state == 2) | `CompressAndCloseFrozenMismatch` (6138) | -| `is_ata` | Must match Compressible.is_ata | `CompressAndCloseIsAtaMismatch` (6168) | - -```rust -// is_ata validation (lines 216-219) -if compression.is_ata() != ext.is_ata() { - return Err(ErrorCode::CompressAndCloseIsAtaMismatch.into()); -} -``` - -### CToken Reset After Compression (lines 68-71) - -```rust -ctoken.base.amount.set(0); -// Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) -// This allows the close_token_account validation to pass for frozen accounts -ctoken.base.set_initialized(); -``` - -### Additional Validation - -- **Amount:** compression_amount == output_amount == ctoken.amount -- **Mint:** output mint matches ctoken mint -- **Version:** Must be ShaFlat (version=3) -- **Uniqueness:** Each CompressAndClose must use different compressed output indices - ---- - -## 3. Decompress - -**Path:** `programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs` - -### Instruction: Transfer2 (101) with CompressionMode::Decompress - -Decompress recreates a CToken account from a compressed token account. For ATAs, the system must verify the ATA derivation and restore the wallet owner. - -### Amount Validation (lines 31-46) - -For ATAs (and compress_to_pubkey), the amount **must match exactly**: - -```rust -if ext_data.is_ata() || compress_to_pubkey { - let input_amount: u64 = inputs.input_token_data.amount.into(); - if compression_amount != input_amount { - return Err(CTokenError::DecompressAmountMismatch.into()); - } -} -``` - -This prevents partial decompression of ATA tokens, ensuring the full balance is always decompressed together. - -### Destination Validation (lines 77-106) - -For ATAs, validation is more complex because of the owner paradox: - -```rust -fn validate_destination( - ctoken: &ZCTokenMut, - destination: &AccountInfo, - input_owner_key: &[u8; 32], - ext_data: &ZCompressedOnlyExtensionInstructionData, - packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, -) -> Result<(), ProgramError> { - // Non-ATA: simple owner match - if !ext_data.is_ata() { - if !pubkey_eq(ctoken.base.owner.array_ref(), input_owner_key) { - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - return Ok(()); - } - - // ATA: destination address == input_owner (ATA pubkey) - if !pubkey_eq(destination.key(), input_owner_key) { - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - - // ATA: wallet owner from owner_index must match CToken owner - let wallet = packed_accounts.get_u8(ext_data.owner_index, "wallet owner")?; - if !pubkey_eq(wallet.key(), ctoken.base.owner.array_ref()) { - return Err(CTokenError::DecompressDestinationMismatch.into()); - } - Ok(()) -} -``` - -### ATA Derivation Verification (Critical Security Check) - -**Path:** `programs/compressed-token/program/src/shared/token_input.rs` (lines 82-120) - -During **input processing** (before decompress.rs runs), the ATA derivation is verified: - -```rust -// For ATA decompress, verify wallet owner and ATA derivation -if data.is_ata != 0 { - // 1. Get wallet owner from owner_index - let wallet_owner = packed_accounts.get(data.owner_index as usize)?; - - // 2. Derive ATA using bump from CompressedOnly extension - let bump_seed = [data.bump]; - let ata_seeds: [&[u8]; 4] = [ - wallet_owner.key().as_ref(), - CTOKEN_PROGRAM_ID.as_ref(), - mint_account.key().as_ref(), - bump_seed.as_ref(), - ]; - let derived_ata = create_program_address(&ata_seeds, &CTOKEN_PROGRAM_ID)?; - - // 3. Verify owner_account (the ATA pubkey) matches derived address - if !pubkey_eq(owner_account.key(), &derived_ata) { - return None; // Causes signer check to fail - } - - // 4. Use wallet owner as signer (ATA can't sign) - signer_account = wallet_owner; -} - -// 5. verify_owner_or_delegate_signer validates wallet_owner is a transaction signer -``` - -**Security Properties:** -- Incorrect `owner_index`: ATA derivation fails or wallet_owner is wrong, signer check fails -- Incorrect `bump`: ATA derivation produces wrong address, validation fails -- Malicious wallet: Not a transaction signer, `verify_owner_or_delegate_signer` fails - -### State Restoration - -1. **Delegate restoration (lines 108-144):** - ```rust - if let Some(delegate_acc) = input_delegate { - ctoken.base.set_delegate(Some(Pubkey::from(*delegate_acc.key())))?; - if delegated_amount > 0 { - ctoken.base.delegated_amount.set(current + delegated_amount); - } - } - ``` - -2. **Withheld fee restoration (lines 146-171):** - ```rust - if fee > 0 { - let fee_ext = ctoken.get_transfer_fee_account_extension_mut(); - fee_ext.add_withheld_amount(fee)?; - } - ``` - -3. **Frozen state restoration (lines 64-67):** - ```rust - if ext_data.is_frozen() { - ctoken.base.set_frozen(); - } - ``` - ---- - -## Validation Summary - -### Creation Validations - -| Validation | Source | Error | -|------------|--------|-------| -| Account uninitialized | create_ata.rs | `IllegalOwner` | -| compression_only set | create_ata.rs | `AtaRequiresCompressionOnly` | -| compress_to_pubkey is None | create_ata.rs | `InvalidInstructionData` | -| Config is ACTIVE | create_ata.rs | `InvalidState` | -| rent_payment != 1 | initialize_ctoken_account.rs | `OneEpochPrefundingNotAllowed` | - -### CompressAndClose Validations - -| Validation | Source | Error | -|------------|--------|-------| -| Owner matches (ATA pubkey) | compress_and_close.rs:103-115 | `CompressAndCloseInvalidOwner` | -| CompressedOnly extension present | compress_and_close.rs:136-145 | `CompressAndCloseMissingCompressedOnlyExtension` (6133) | -| delegated_amount matches | compress_and_close.rs:177-181 | `CompressAndCloseDelegatedAmountMismatch` (6135) | -| delegate pubkey matches | compress_and_close.rs:183-194 | `CompressAndCloseInvalidDelegate` (6136) | -| withheld_transfer_fee matches | compress_and_close.rs:196-209 | `CompressAndCloseWithheldFeeMismatch` (6137) | -| is_frozen matches | compress_and_close.rs:211-214 | `CompressAndCloseFrozenMismatch` (6138) | -| is_ata matches | compress_and_close.rs:216-219 | `CompressAndCloseIsAtaMismatch` (6168) | -| Amount matches | compress_and_close.rs:117-121 | `CompressAndCloseAmountMismatch` | -| Mint matches | compress_and_close.rs:123-129 | `CompressAndCloseInvalidMint` | -| Version is ShaFlat | compress_and_close.rs:131-134 | `CompressAndCloseInvalidVersion` | - -### Decompress Validations - -| Validation | Source | Error | -|------------|--------|-------| -| Amount matches (for ATA) | decompress.rs:31-46 | `DecompressAmountMismatch` (18064) | -| Destination = input_owner (ATA pubkey) | decompress.rs:89-93 | `DecompressDestinationMismatch` (18057) | -| Wallet owner = CToken owner | decompress.rs:96-102 | `DecompressDestinationMismatch` (18057) | -| ATA derivation correct | token_input.rs:79-117 | Error during input processing | -| Delegate present if delegated_amount > 0 | decompress.rs:139-142 | `DecompressDelegatedAmountWithoutDelegate` (18059) | -| TransferFeeAccount ext if withheld_fee > 0 | decompress.rs:160-168 | `DecompressWithheldFeeWithoutExtension` (18060) | - ---- - -## Data Preservation Matrix - -| Field | Preserved | Storage Location | Restored | Notes | -|-------|-----------|------------------|----------|-------| -| is_ata | Yes | CompressedOnly.is_ata | Validated | Must match source | -| delegated_amount | Yes | CompressedOnly.delegated_amount | Yes | Restored to CToken | -| withheld_transfer_fee | Yes | CompressedOnly.withheld_transfer_fee | Yes | Restored to TransferFeeAccount ext | -| is_frozen | Yes | CompressedOnly.is_frozen | Yes | Restored via set_frozen() | -| bump | Yes | CompressedOnly.bump | Used | For ATA derivation verification | -| owner_index | Yes | CompressedOnly.owner_index | Used | Identifies wallet owner account | -| delegate pubkey | Yes | Passed as input account | Yes | Restored to CToken.delegate | -| amount | No | New from compression | N/A | Set to 0 after compress | -| close_authority | No | Not preserved | N/A | Cannot be set on ATAs anyway | - ---- - -## Security: Why is_ata Flag is Trustworthy - -The `is_ata` flag in the Compressible extension is **program-controlled** and cannot be spoofed: - -1. **Set during creation only:** The flag is set by `create_ata.rs` when creating an ATA, or `create.rs` for regular accounts -2. **Account owned by program:** CToken accounts are owned by the CToken program, preventing external modification -3. **Validated during CompressAndClose:** The `is_ata` in Compressible extension must match CompressedOnly extension (line 217) - -**Attack prevention:** -- Cannot create non-ATA with `is_ata=1`: Program controls flag during creation -- Cannot modify existing account's flag: Account is program-owned -- Cannot spoof in CompressedOnly: Must match Compressible extension - ---- - -## ATA Owner Paradox - -ATAs present a unique challenge because they are PDAs and **cannot sign transactions**. This creates a paradox during compression: - -1. **The compressed token owner must be verifiable** - so we use the ATA pubkey as owner -2. **Someone must sign the decompress transaction** - but the ATA can't sign - -**Solution:** - -The CompressedOnly extension stores: -- `owner_index`: Index to the wallet owner account in packed_accounts -- `bump`: The PDA bump for ATA derivation - -During decompress: -1. Wallet owner (from `owner_index`) provides the signature -2. System verifies: `derive_ata(wallet_owner, mint, bump) == input_owner` -3. This proves the wallet owner is the legitimate owner of the ATA - -``` -Creation: - wallet_owner + mint + bump -> ATA pubkey - | - v -Compress: [ATA as owner] - | - v -Decompress: [verify derivation] - wallet_owner (signer) <-------- owner_index - mint <-------- from input - bump <-------- from CompressedOnly - | - v - derived == input_owner? -``` - ---- - -## Error Reference - -| Error | Code | Description | -|-------|------|-------------| -| `AtaRequiresCompressionOnly` | - | ATA created without compression_only flag | -| `CompressAndCloseMissingCompressedOnlyExtension` | 6133 | ATA CompressAndClose missing required extension | -| `CompressAndCloseIsAtaMismatch` | 6168 | is_ata flag mismatch between extensions | -| `CompressAndCloseInvalidOwner` | 6089 | Owner validation failed (ATA pubkey expected) | -| `CompressAndCloseDelegatedAmountMismatch` | 6135 | Delegated amount not preserved correctly | -| `CompressAndCloseInvalidDelegate` | 6136 | Delegate pubkey mismatch | -| `CompressAndCloseWithheldFeeMismatch` | 6137 | Withheld fee not preserved correctly | -| `CompressAndCloseFrozenMismatch` | 6138 | Frozen state not preserved correctly | -| `DecompressDestinationMismatch` | 18057 | Destination/owner validation failed | -| `DecompressAmountMismatch` | 18064 | Amount mismatch for ATA decompress | -| `DecompressDelegatedAmountWithoutDelegate` | 18059 | delegated_amount > 0 but no delegate account | -| `DecompressWithheldFeeWithoutExtension` | 18060 | Withheld fee but no TransferFeeAccount extension | - ---- - -## Related Documentation - -- `docs/ctoken/CREATE.md` - Full ATA creation documentation -- `docs/compressed_token/TRANSFER2.md` - Transfer2 instruction including compress/decompress -- `docs/EXTENSIONS.md` - CompressedOnly extension behavior and validation -- `program-libs/compressible/docs/RENT.md` - Rent management for compressible accounts diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs index 888dbf4dd1..94fa8322b5 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/accounts.rs @@ -136,7 +136,7 @@ impl<'info> MintActionAccounts<'info> { let in_output_queue = iter.next_option("in_output_queue", !config.create_mint)?; // Only needed for minting to compressed token accounts let tokens_out_queue = - iter.next_option("tokens_out_queue", config.has_mint_to_actions)?; + iter.next_option("tokens_out_queue", config.require_token_output_queue)?; let mint_accounts = MintActionAccounts { mint_signer, @@ -349,7 +349,7 @@ pub struct AccountsConfig { /// When true, the CMint account is the decompressed (compressed account is empty). pub cmint_decompressed: bool, /// 5. Mint - pub has_mint_to_actions: bool, + pub require_token_output_queue: bool, /// 6. Compressed mint is created or DecompressMint action is present. pub with_mint_signer: bool, /// 7. Compressed mint is created. @@ -471,7 +471,7 @@ impl AccountsConfig { msg!("CMint decompressed not allowed when writing to cpi context"); return Err(ErrorCode::CpiContextSetNotUsable.into()); } - let has_mint_to_actions = parsed_instruction_data + let require_token_output_queue = parsed_instruction_data .actions .iter() .any(|action| matches!(action, ZAction::MintToCompressed(_))); @@ -479,7 +479,7 @@ impl AccountsConfig { with_cpi_context, write_to_cpi_context, cmint_decompressed, - has_mint_to_actions, + require_token_output_queue, with_mint_signer, create_mint: parsed_instruction_data.create_mint.is_some(), has_decompress_mint_action, @@ -489,7 +489,7 @@ impl AccountsConfig { // For MintToCompressed actions // - needed for tokens_out_queue (only MintToCompressed creates new compressed outputs) // - MintToCToken mints to existing decompressed accounts, doesn't need tokens_out_queue - let has_mint_to_actions = parsed_instruction_data + let require_token_output_queue = parsed_instruction_data .actions .iter() .any(|action| matches!(action, ZAction::MintToCompressed(_))); @@ -498,7 +498,7 @@ impl AccountsConfig { with_cpi_context, write_to_cpi_context, cmint_decompressed, - has_mint_to_actions, // TODO: evaluate wether to rename to require token output queue + require_token_output_queue, with_mint_signer, create_mint: parsed_instruction_data.create_mint.is_some(), has_decompress_mint_action, diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index a378118cd0..f9ea682006 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -215,9 +215,8 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun .map(|ctx| ctx.first_set_context || ctx.set_context) .unwrap_or(false); - // 3. has_mint_to_actions - // Only MintToCompressed counts - MintToCToken mints to existing decompressed accounts - let has_mint_to_actions = data + // 3. require_token_output_queue (only MintToCompressed creates new compressed outputs) + let require_token_output_queue = data .actions .iter() .any(|action| matches!(action, Action::MintToCompressed(_))); @@ -249,7 +248,7 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun has_compress_and_close_cmint_action, write_to_cpi_context, cmint_decompressed, - has_mint_to_actions, + require_token_output_queue, with_mint_signer, create_mint, } @@ -332,7 +331,7 @@ fn check_if_config_should_error(instruction_data: &MintActionCompressedInstructi .any(|action| matches!(action, Action::MintToCToken(_))); // Check for MintToCompressed actions - let has_mint_to_actions = instruction_data + let require_token_output_queue = instruction_data .actions .iter() .any(|action| matches!(action, Action::MintToCompressed(_))); @@ -347,8 +346,8 @@ fn check_if_config_should_error(instruction_data: &MintActionCompressedInstructi // Error conditions matching AccountsConfig::new: // 1. has_mint_to_ctoken (MintToCToken actions not allowed) - // 2. cmint_decompressed && has_mint_to_actions (mint decompressed + MintToCompressed not allowed) - has_mint_to_ctoken || (cmint_decompressed && has_mint_to_actions) + // 2. cmint_decompressed && require_token_output_queue (mint decompressed + MintToCompressed not allowed) + has_mint_to_ctoken || (cmint_decompressed && require_token_output_queue) } else { false } From 39ccd3f96a61566237558c27a7389855e79a080b Mon Sep 17 00:00:00 2001 From: ananas Date: Sun, 11 Jan 2026 20:50:43 +0000 Subject: [PATCH 37/38] cleanup --- .../compressible/src/compression_info.rs | 32 ------------------- .../src/state/ctoken/zero_copy.rs | 7 ---- .../src/state/mint/compressed_mint.rs | 13 ++++---- .../ctoken-interface/src/state/mint/top_up.rs | 7 ++-- .../compressed_token/mint_action/processor.rs | 5 +-- .../program/src/shared/compressible_top_up.rs | 18 +++-------- 6 files changed, 15 insertions(+), 67 deletions(-) diff --git a/program-libs/compressible/src/compression_info.rs b/program-libs/compressible/src/compression_info.rs index affac0bc34..9d4c4b57dc 100644 --- a/program-libs/compressible/src/compression_info.rs +++ b/program-libs/compressible/src/compression_info.rs @@ -12,16 +12,6 @@ use crate::{ AnchorDeserialize, AnchorSerialize, }; -/// Trait for types that can calculate top-up lamports for compressible accounts. -pub trait CalculateTopUp { - fn calculate_top_up_lamports( - &self, - num_bytes: u64, - current_slot: u64, - current_lamports: u64, - ) -> Result; -} - /// Compressible extension for ctoken accounts. #[derive( Debug, @@ -144,28 +134,6 @@ impl_is_compressible!(CompressionInfo); impl_is_compressible!(ZCompressionInfo<'_>); impl_is_compressible!(ZCompressionInfoMut<'_>); -// Implement CalculateTopUp trait for all compressible extension types -macro_rules! impl_calculate_top_up { - ($struct_name:ty) => { - impl CalculateTopUp for $struct_name { - #[inline(always)] - fn calculate_top_up_lamports( - &self, - num_bytes: u64, - current_slot: u64, - current_lamports: u64, - ) -> Result { - // Delegate to the inherent method - Self::calculate_top_up_lamports(self, num_bytes, current_slot, current_lamports) - } - } - }; -} - -impl_calculate_top_up!(CompressionInfo); -impl_calculate_top_up!(ZCompressionInfo<'_>); -impl_calculate_top_up!(ZCompressionInfoMut<'_>); - // Unified macro to implement get_last_funded_epoch for all extension types macro_rules! impl_get_last_paid_epoch { ($struct_name:ty) => { diff --git a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs index f317a19074..49dc27104a 100644 --- a/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs +++ b/program-libs/ctoken-interface/src/state/ctoken/zero_copy.rs @@ -189,7 +189,6 @@ impl<'a> ZeroCopyAt<'a> for CToken { // Check if there are extensions by looking at account_type byte at position 165 if !bytes.is_empty() { - // && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT should throw an error let account_type = bytes[0]; // Skip account_type byte let bytes = &bytes[1..]; @@ -230,7 +229,6 @@ impl<'a> ZeroCopyAtMut<'a> for CToken { // Check if there are extensions by looking at account_type byte at position 165 if !bytes.is_empty() { - // && bytes[0] == ACCOUNT_TYPE_TOKEN_ACCOUNT should throw an error let account_type = bytes[0]; // Skip account_type byte let bytes = &mut bytes[1..]; @@ -529,7 +527,6 @@ impl CToken { } /// Deserialize a CToken from account info with validation using zero-copy. - /// Uses unsafe lifetime extension to avoid returning the borrow guard. /// /// Checks: /// 1. Account is owned by the CTOKEN program @@ -575,10 +572,6 @@ impl CToken { /// 2. Account is initialized (state != 0) /// 3. Account type is ACCOUNT_TYPE_TOKEN_ACCOUNT (byte 165 == 2) /// 4. No trailing bytes after the CToken structure - /// - /// Safety: The returned ZCTokenMut references the account data which is valid - /// for the duration of the transaction. The caller must ensure the account - /// is not accessed through other means while this mutable reference exists. #[inline(always)] pub fn from_account_info_mut_checked<'a>( account_info: &pinocchio::account_info::AccountInfo, diff --git a/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs b/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs index d11e6e4307..ca6fec13f3 100644 --- a/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs +++ b/program-libs/ctoken-interface/src/state/mint/compressed_mint.rs @@ -3,10 +3,13 @@ use light_compressed_account::Pubkey; use light_compressible::compression_info::CompressionInfo; use light_hasher::{sha256::Sha256BE, Hasher}; use light_zero_copy::{ZeroCopy, ZeroCopyMut}; +use pinocchio::account_info::AccountInfo; #[cfg(feature = "solana")] use solana_msg::msg; -use crate::{state::ExtensionStruct, AnchorDeserialize, AnchorSerialize, CTokenError}; +use crate::{ + state::ExtensionStruct, AnchorDeserialize, AnchorSerialize, CTokenError, CTOKEN_PROGRAM_ID, +}; /// AccountType::Mint discriminator value pub const ACCOUNT_TYPE_MINT: u8 = 1; @@ -72,7 +75,6 @@ pub struct CompressedMintMetadata { /// Pda with seed address of compressed mint pub mint: Pubkey, /// Address of the compressed account the mint is stored in. - /// Derived from the associated spl mint pubkey. pub compressed_address: [u8; 32], } @@ -96,12 +98,9 @@ impl CompressedMint { /// /// Note: CMint accounts follow SPL token mint pattern (no discriminator). /// Validation is done via owner check + PDA derivation (caller responsibility). - pub fn from_account_info_checked( - program_id: &[u8; 32], - account_info: &pinocchio::account_info::AccountInfo, - ) -> Result { + pub fn from_account_info_checked(account_info: &AccountInfo) -> Result { // 1. Check program ownership - if !account_info.is_owned_by(program_id) { + if !account_info.is_owned_by(&CTOKEN_PROGRAM_ID) { #[cfg(feature = "solana")] msg!("CMint account has invalid owner"); return Err(CTokenError::InvalidCMintOwner); diff --git a/program-libs/ctoken-interface/src/state/mint/top_up.rs b/program-libs/ctoken-interface/src/state/mint/top_up.rs index 85ddbc04f0..f5d92057e1 100644 --- a/program-libs/ctoken-interface/src/state/mint/top_up.rs +++ b/program-libs/ctoken-interface/src/state/mint/top_up.rs @@ -47,7 +47,7 @@ pub fn cmint_top_up_lamports_from_slice( } /// Calculate top-up lamports from a CMint AccountInfo. -/// Verifies account owner matches expected program. Returns None if owner mismatch or invalid. +/// Verifies account owner is the CToken program. Returns None if owner mismatch or invalid. /// Pass `current_slot` as 0 to fetch from Clock sysvar; non-zero values are used directly. #[cfg(target_os = "solana")] #[inline(always)] @@ -55,12 +55,11 @@ pub fn cmint_top_up_lamports_from_slice( pub fn cmint_top_up_lamports_from_account_info( account_info: &AccountInfo, current_slot: &mut u64, - program_id: &[u8; 32], ) -> Option { use pinocchio::sysvars::{clock::Clock, Sysvar}; - // Check owner matches expected program - if !account_info.is_owned_by(program_id) { + // Check owner is CToken program + if !account_info.is_owned_by(&crate::CTOKEN_PROGRAM_ID) { return None; } diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs index 8add5d0b36..a9ab508107 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/processor.rs @@ -65,10 +65,7 @@ pub fn process_mint_action( let cmint_account = validated_accounts .get_cmint() .ok_or(ErrorCode::MintActionMissingCMintAccount)?; - CompressedMint::from_account_info_checked( - &crate::LIGHT_CPI_SIGNER.program_id, - cmint_account, - )? + CompressedMint::from_account_info_checked(cmint_account)? }; let (config, mut cpi_bytes, _) = diff --git a/programs/compressed-token/program/src/shared/compressible_top_up.rs b/programs/compressed-token/program/src/shared/compressible_top_up.rs index 8dc9eb956c..291a630908 100644 --- a/programs/compressed-token/program/src/shared/compressible_top_up.rs +++ b/programs/compressed-token/program/src/shared/compressible_top_up.rs @@ -14,8 +14,6 @@ use super::{ convert_program_error, transfer_lamports::{multi_transfer_lamports, Transfer}, }; -#[cfg(target_os = "solana")] -use crate::LIGHT_CPI_SIGNER; /// Calculate and execute top-up transfers for compressible CMint and CToken accounts. /// CMint always has compression info. CToken requires Compressible extension or errors. @@ -27,7 +25,7 @@ use crate::LIGHT_CPI_SIGNER; /// * `max_top_up` - Maximum lamports for top-ups combined (0 = no limit) #[inline(always)] #[profile] -#[allow(unused_mut)] +#[allow(unused)] pub fn calculate_and_execute_compressible_top_ups<'a>( cmint: &'a AccountInfo, ctoken: &'a AccountInfo, @@ -52,11 +50,7 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( // Calculate CMint top-up using optimized function (owner check inside) #[cfg(target_os = "solana")] - if let Some(amount) = cmint_top_up_lamports_from_account_info( - cmint, - &mut current_slot, - &LIGHT_CPI_SIGNER.program_id, - ) { + if let Some(amount) = cmint_top_up_lamports_from_account_info(cmint, &mut current_slot) { transfers[0].amount = amount; lamports_budget = lamports_budget.saturating_sub(amount); } @@ -68,10 +62,8 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( transfers[1].amount = amount; lamports_budget = lamports_budget.saturating_sub(amount); } - #[cfg(not(target_os = "solana"))] - let _ = (cmint, ctoken, &mut current_slot); // Suppress unused warnings - // Exit early if no compressible accounts + // Exit early if no compressible accounts (current_slot remains 0 if no top-ups calculated) if current_slot == 0 { return Ok(()); } @@ -92,8 +84,8 @@ pub fn calculate_and_execute_compressible_top_ups<'a>( /// Process compression top-up using embedded compression info. /// Uses stored rent_exemption_paid from CompressionInfo instead of querying Rent sysvar. #[inline(always)] -pub fn process_compression_top_up( - compression: &T, +pub fn process_compression_top_up( + compression: &light_compressible::compression_info::ZCompressionInfoMut<'_>, account_info: &AccountInfo, current_slot: &mut u64, transfer_amount: &mut u64, From 9f46ad3defa38694df78bef681c2d2760148820c Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 12 Jan 2026 13:48:26 +0000 Subject: [PATCH 38/38] fix: as u32 -> try into, cleanup comments --- .../docs/compressed_token/TRANSFER2.md | 70 +++++++++++++++---- .../mint_action/actions/decompress_mint.rs | 6 +- .../mint_action/mint_output.rs | 8 ++- .../src/shared/initialize_ctoken_account.rs | 6 +- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md index b96b6ed494..c6feb2c5b3 100644 --- a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -4,13 +4,14 @@ | I want to... | Go to | |-------------|-------| -| Transfer compressed tokens | → [Path B](#path-b-with-compressed-accounts-full-transfer-operations) (line 184) + [System accounts](#system-accounts-when-compressed-accounts-involved) (line 77) | -| Only compress/decompress (no transfers) | → [Path A](#path-a-no-compressed-accounts-compressions-only-operations) (line 157) + [Compressions-only accounts](#compressions-only-accounts-when-no_compressed_accounts) (line 112) | -| Compress SPL tokens | → [SPL compression](#spl-token-compressiondecompression) (line 240) | -| Compress CToken accounts | → [CToken compression](#ctoken-compressiondecompression-srctransfer2compressionctoken) (line 250) | -| Close compressible account (forester) | → [CompressAndClose](#for-compressandclose) (line 274) - compression_authority only | -| Use CPI context | → [Write mode](#cpi-context-write-path) (line 215) or [Execute mode](#cpi-context-support-for-cross-program-invocations) (line 38) | -| Debug errors | → [Error reference](#errors) (line 329) | +| Understand which accounts to pass | → [Path Selection and Account Requirements](#path-selection-and-account-requirements) | +| Transfer compressed tokens | → [Path B](#path-b-with-compressed-accounts-full-transfer-operations) + [System accounts](#system-accounts-when-compressed-accounts-involved) | +| Only compress/decompress (no transfers) | → [Path A](#path-a-no-compressed-accounts-compressions-only-operations) + [Compressions-only accounts](#compressions-only-accounts-path-a-when-no_compressed_accountstrue) | +| Compress SPL tokens | → [SPL compression](#spl-token-compressiondecompression) | +| Compress CToken accounts | → [CToken compression](#ctoken-compressiondecompression-srctransfer2compressionctoken) | +| Close compressible account (forester) | → [CompressAndClose](#for-compressandclose) - compression_authority only | +| Use CPI context | → [Write mode](#cpi-context-write-path) or [Execute mode](#cpi-context-support-for-cross-program-invocations) | +| Debug errors | → [Error reference](#errors) | **discriminator:** 101 **enum:** `InstructionType::Transfer2` @@ -109,17 +110,62 @@ System accounts (when compressed accounts involved): - (mutable) - For storing CPI context data for later execution -Compressions-only accounts (when no_compressed_accounts): -Note: In compressions-only mode, these accounts replace the system accounts above +--- -11. compressions_only_cpi_authority_pda - - PDA for compression operations +### Path Selection and Account Requirements + +**Path selection logic:** The instruction determines which path to execute based on the `no_compressed_accounts` flag computed from instruction data: +``` +no_compressed_accounts = in_token_data.is_empty() && out_token_data.is_empty() +``` + +When `no_compressed_accounts=true`, the instruction executes **Path A** (compressions-only). +When `no_compressed_accounts=false`, the instruction executes **Path B** (full transfer with system CPI). + +**Account layout is mutually exclusive:** Callers must pass EITHER the Path A accounts OR the Path B accounts, never both. The account parser reads accounts sequentially and expects a specific layout based on the path: + +- **Path A layout:** `[cpi_authority_pda, fee_payer, ...packed_accounts]` +- **Path B layout:** `[light_system_program, fee_payer, cpi_authority_pda, registered_program_pda, account_compression_authority, account_compression_program, system_program, (optional accounts...), ...packed_accounts]` + +**Account Requirements by Path:** + +| Account | Path A (compressions-only) | Path B (with compressed accounts) | +|---------|---------------------------|----------------------------------| +| light_system_program | Not used | Required (position 0) | +| fee_payer | Required (position 1, signer) | Required (position 1, signer) | +| cpi_authority_pda | Required (position 0) | Required (position 2) | +| registered_program_pda | Not used | Required (position 3) | +| account_compression_authority | Not used | Required (position 4) | +| account_compression_program | Not used | Required (position 5) | +| system_program | Not used | Required (position 6) | +| sol_pool_pda | Not used | Optional (when lamport imbalance) | +| sol_decompression_recipient | Not used | Optional (when decompressing SOL) | +| cpi_context_account | Not used | Optional (for CPI context) | +| packed_accounts | After position 1 | After system/optional accounts | + +**Note:** `cpi_authority_pda` is the **same PDA** in both paths (seeds: `[CPI_AUTHORITY_SEED]`), just at different positions. + +--- + +### Compressions-only accounts (Path A: when no_compressed_accounts=true) + +When `no_compressed_accounts=true`, pass ONLY these accounts (do NOT include the Path B system accounts): + +1. cpi_authority_pda (position 0) + - PDA for signing SPL token transfers during compress/decompress - Seeds: [CPI_AUTHORITY_SEED] + - Same PDA as Path B, different position -12. compressions_only_fee_payer +2. fee_payer (position 1) - (signer, mutable) - Pays for compression/decompression operations +**Path A errors:** +- `CompressionsOnlyMissingCpiAuthority` (6097): cpi_authority_pda not provided at position 0 +- `CompressionsOnlyMissingFeePayer` (6096): fee_payer not provided at position 1 + +--- + Packed accounts (dynamic indexing): - merkle tree and queue accounts - For compressed account storage, nullifier tracking and output storage (must come first, identified by ACCOUNT_COMPRESSION_PROGRAM ownership) - mint accounts - Referenced by index in instruction data (account doesn't need to exist, only pubkey is used) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs index a2961c09bf..69303471f9 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/actions/decompress_mint.rs @@ -132,9 +132,11 @@ pub fn process_decompress_mint_action( .len(); // 7a.1. Store rent exemption at creation (only query Rent sysvar here, never again) - let rent_exemption_paid = Rent::get() + let rent_exemption_paid: u32 = Rent::get() .map_err(|_| ProgramError::UnsupportedSysvar)? - .minimum_balance(account_size) as u32; + .minimum_balance(account_size) + .try_into() + .map_err(|_| ProgramError::ArithmeticOverflow)?; compressed_mint.compression.rent_exemption_paid = rent_exemption_paid; // 7b. Calculate Light Protocol rent (base_rent + bytes * lamports_per_byte * epochs + compression_cost) diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 175f3ae2d7..490a50eae2 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -149,13 +149,14 @@ fn serialize_decompressed_mint( // STEP 3: Calculate rent exemption deficit FIRST (based on final size) let num_bytes = required_size as u64; - let current_lamports = cmint_account.lamports(); let rent_exemption = get_rent_exemption_lamports(num_bytes).map_err(|_| ErrorCode::CMintRentExemptionFailed)?; // Only update rent_exemption_paid if new rent exemption is higher // (sponsor should get back what they originally paid) - let rent_exemption_u32 = rent_exemption as u32; + let rent_exemption_u32: u32 = rent_exemption + .try_into() + .map_err(|_| ProgramError::ArithmeticOverflow)?; let mut deficit = 0u64; if rent_exemption_u32 > compressed_mint.compression.rent_exemption_paid { deficit = (rent_exemption_u32 - compressed_mint.compression.rent_exemption_paid) as u64; @@ -164,6 +165,7 @@ fn serialize_decompressed_mint( // STEP 4: Add compressible top-up if not a fresh decompress if !accounts_config.has_decompress_mint_action { + let current_lamports = cmint_account.lamports(); let current_slot = Clock::get().map_err(convert_program_error)?.slot; let top_up = compressed_mint .compression @@ -173,7 +175,7 @@ fn serialize_decompressed_mint( deficit = deficit.saturating_add(top_up); } - // STEP 5: Single unified transfer if needed + // STEP 5: Transfer lamports if needed if deficit > 0 { let fee_payer = validated_accounts .executing 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 c3c6afe604..2f10f2f5e0 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -80,9 +80,11 @@ pub fn create_compressible_account<'info>( // Get rent exemption from Rent sysvar (only place we query it - store for later use) #[cfg(target_os = "solana")] - let rent_exemption_paid = Rent::get() + let rent_exemption_paid: u32 = Rent::get() .map_err(|_| ProgramError::UnsupportedSysvar)? - .minimum_balance(account_size as usize) as u32; + .minimum_balance(account_size as usize) + .try_into() + .map_err(|_| ProgramError::ArithmeticOverflow)?; #[cfg(not(target_os = "solana"))] let rent_exemption_paid = 0;