diff --git a/.github/actions/setup-and-build/action.yml b/.github/actions/setup-and-build/action.yml index 1d0113cc02..8695737858 100644 --- a/.github/actions/setup-and-build/action.yml +++ b/.github/actions/setup-and-build/action.yml @@ -197,6 +197,8 @@ runs: target/deploy/create_address_test_program.so target/deploy/sdk_anchor_test.so target/deploy/sdk-compressible-test.so + target/deploy/csdk_anchor_derived_test.so + target/deploy/csdk_anchor_full_derived_test.so key: ${{ runner.os }}-program-tests-${{ hashFiles('program-tests/**/Cargo.toml', 'program-tests/**/Cargo.lock', 'program-tests/**/*.rs', 'test-programs/**/Cargo.toml', 'test-programs/**/*.rs', 'sdk-tests/**/Cargo.toml', 'sdk-tests/**/*.rs') }} restore-keys: | ${{ runner.os }}-program-tests- diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 8f19fe0e6c..bfae7f54a9 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -50,7 +50,7 @@ jobs: - program: native sub-tests: '["cargo-test-sbf -p sdk-native-test", "cargo-test-sbf -p sdk-v1-native-test", "cargo-test-sbf -p client-test"]' - program: anchor & pinocchio - sub-tests: '["cargo-test-sbf -p sdk-anchor-test", "cargo-test-sbf -p sdk-compressible-test", "cargo-test-sbf -p sdk-pinocchio-v1-test", "cargo-test-sbf -p sdk-pinocchio-v2-test", "cargo-test-sbf -p pinocchio-nostd-test"]' + sub-tests: '["cargo-test-sbf -p sdk-anchor-test", "cargo-test-sbf -p sdk-compressible-test", "cargo-test-sbf -p csdk-anchor-derived-test", "cargo-test-sbf -p csdk-anchor-full-derived-test", "cargo-test-sbf -p sdk-pinocchio-v1-test", "cargo-test-sbf -p sdk-pinocchio-v2-test", "cargo-test-sbf -p pinocchio-nostd-test"]' - program: token test sub-tests: '["cargo-test-sbf -p sdk-token-test"]' - program: sdk-libs diff --git a/Cargo.lock b/Cargo.lock index aba5201189..581f6b5b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "Inflector" @@ -1664,6 +1664,78 @@ dependencies = [ "subtle", ] +[[package]] +name = "csdk-anchor-derived-test" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl 0.31.1 (git+https://github.com/lightprotocol/anchor?rev=d8a2b3d9)", + "bincode", + "borsh 0.10.4", + "light-client", + "light-compressed-account", + "light-compressed-token-sdk", + "light-compressed-token-types", + "light-compressible", + "light-compressible-client", + "light-ctoken-types", + "light-hasher", + "light-macros", + "light-program-test", + "light-sdk", + "light-sdk-macros", + "light-sdk-types", + "light-test-utils", + "light-token-client", + "solana-account", + "solana-instruction", + "solana-keypair", + "solana-logger", + "solana-program", + "solana-pubkey 2.4.0", + "solana-sdk", + "solana-signature", + "solana-signer", + "tokio", +] + +[[package]] +name = "csdk-anchor-full-derived-test" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl 0.31.1 (git+https://github.com/lightprotocol/anchor?rev=d8a2b3d9)", + "bincode", + "borsh 0.10.4", + "light-client", + "light-compressed-account", + "light-compressed-token-sdk", + "light-compressed-token-types", + "light-compressible", + "light-compressible-client", + "light-ctoken-types", + "light-hasher", + "light-macros", + "light-program-test", + "light-sdk", + "light-sdk-macros", + "light-sdk-types", + "light-test-utils", + "light-token-client", + "solana-account", + "solana-account-info", + "solana-instruction", + "solana-keypair", + "solana-logger", + "solana-program", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-sdk", + "solana-signature", + "solana-signer", + "tokio", +] + [[package]] name = "ctr" version = "0.9.2" @@ -3720,6 +3792,7 @@ dependencies = [ "anchor-lang", "borsh 0.10.4", "light-client", + "light-compressed-account", "light-sdk", "solana-account", "solana-instruction", @@ -4061,6 +4134,7 @@ dependencies = [ "borsh 0.10.4", "light-account-checks", "light-compressed-account", + "light-compressible", "light-concurrent-merkle-tree", "light-ctoken-types", "light-hasher", @@ -6110,6 +6184,7 @@ dependencies = [ "solana-sdk", "solana-signature", "solana-signer", + "solana-system-interface 1.0.0", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 8d2ed244ee..3d5549d32a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,8 @@ members = [ "sdk-tests/sdk-v1-native-test", "sdk-tests/sdk-token-test", "sdk-tests/sdk-compressible-test", + "sdk-tests/csdk-anchor-derived-test", + "sdk-tests/csdk-anchor-full-derived-test", "forester-utils", "forester", "sparse-merkle-tree", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3335823363..78c30c3872 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -442,7 +442,9 @@ importers: programs: {} - sdk-tests/sdk-compressible-test: {} + sdk-tests/csdk-anchor-derived-test: {} + + sdk-tests/csdk-anchor-full-derived-test: {} sdk-tests/sdk-anchor-test: dependencies: @@ -481,6 +483,8 @@ importers: specifier: ^4.3.5 version: 4.9.5 + sdk-tests/sdk-compressible-test: {} + tsconfig: {} packages: diff --git a/program-tests/registry-test/tests/tests.rs b/program-tests/registry-test/tests/tests.rs index 5460fdb341..6d44bd8872 100644 --- a/program-tests/registry-test/tests/tests.rs +++ b/program-tests/registry-test/tests/tests.rs @@ -191,7 +191,7 @@ async fn test_initialize_protocol_config() { config: ProgramTestConfig::default(), transaction_counter: 0, pre_context: None, - auto_compress_programs: Vec::new(), + auto_mine_cold_state_programs: Vec::new(), }; let payer = rpc.get_payer().insecure_clone(); diff --git a/programs/system/Cargo.toml b/programs/system/Cargo.toml index 9f4924e945..3ffeb615cb 100644 --- a/programs/system/Cargo.toml +++ b/programs/system/Cargo.toml @@ -25,8 +25,8 @@ reinit = [] default = ["reinit"] test-sbf = [] readonly = [] -profile-program = ["light-program-profiler/profile-program"] -profile-heap = ["light-program-profiler/profile-heap", "dep:light-heap"] +profile-program = [] +profile-heap = ["dep:light-heap"] custom-heap = [] [dependencies] diff --git a/sdk-libs/compressed-token-sdk/src/decompress_runtime.rs b/sdk-libs/compressed-token-sdk/src/decompress_runtime.rs new file mode 100644 index 0000000000..4114d8061c --- /dev/null +++ b/sdk-libs/compressed-token-sdk/src/decompress_runtime.rs @@ -0,0 +1,160 @@ +//! Runtime helpers for token decompression. +use light_ctoken_types::instructions::transfer2::MultiInputTokenDataWithContext; +use light_sdk::{cpi::v2::CpiAccounts, instruction::ValidityProof}; +use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; +use solana_account_info::AccountInfo; +use solana_msg::msg; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +use crate::compat::PackedCTokenData; + +/// Trait for getting token account seeds. +pub trait CTokenSeedProvider: Copy { + /// Type of accounts struct needed for seed derivation. + type Accounts<'info>; + + /// Get seeds for the token account PDA (used for decompression). + fn get_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [AccountInfo<'info>], + ) -> Result<(Vec>, Pubkey), ProgramError>; + + /// Get authority seeds for signing during compression. + fn get_authority_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [AccountInfo<'info>], + ) -> Result<(Vec>, Pubkey), ProgramError>; +} + +/// Token decompression processor. +#[inline(never)] +#[allow(clippy::too_many_arguments)] +pub fn process_decompress_tokens_runtime<'info, 'a, 'b, V, A>( + accounts_for_seeds: &A, + remaining_accounts: &[AccountInfo<'info>], + fee_payer: &AccountInfo<'info>, + ctoken_program: &AccountInfo<'info>, + ctoken_rent_sponsor: &AccountInfo<'info>, + ctoken_cpi_authority: &AccountInfo<'info>, + ctoken_config: &AccountInfo<'info>, + config: &AccountInfo<'info>, + ctoken_accounts: Vec<( + PackedCTokenData, + CompressedAccountMetaNoLamportsNoAddress, + )>, + proof: ValidityProof, + cpi_accounts: &CpiAccounts<'b, 'info>, + post_system_accounts: &[AccountInfo<'info>], + has_pdas: bool, + program_id: &Pubkey, +) -> Result<(), ProgramError> +where + V: CTokenSeedProvider = A>, + A: 'info, +{ + let mut token_decompress_indices: Box> = + Box::new(Vec::with_capacity(ctoken_accounts.len())); + let mut token_signers_seed_groups: Vec>> = + Vec::with_capacity(ctoken_accounts.len()); + let packed_accounts = post_system_accounts; + + let authority = cpi_accounts + .authority() + .map_err(|_| ProgramError::MissingRequiredSignature)?; + let cpi_context = cpi_accounts + .cpi_context() + .map_err(|_| ProgramError::MissingRequiredSignature)?; + + for (token_data, meta) in ctoken_accounts.into_iter() { + let owner_index: u8 = token_data.token_data.owner; + let mint_index: u8 = token_data.token_data.mint; + let mint_info = &packed_accounts[mint_index as usize]; + let owner_info = &packed_accounts[owner_index as usize]; + + // Use trait method to get seeds (program-specific) + let (ctoken_signer_seeds, derived_token_account_address) = token_data + .variant + .get_seeds(accounts_for_seeds, remaining_accounts)?; + + if derived_token_account_address != *owner_info.key { + msg!( + "derived_token_account_address: {:?}", + derived_token_account_address + ); + msg!("owner_info.key: {:?}", owner_info.key); + return Err(ProgramError::InvalidAccountData); + } + + let seed_refs: Vec<&[u8]> = ctoken_signer_seeds.iter().map(|s| s.as_slice()).collect(); + let seeds_slice: &[&[u8]] = &seed_refs; + + crate::instructions::create_token_account::create_ctoken_account_signed( + *program_id, + fee_payer.clone(), + owner_info.clone(), + mint_info.clone(), + *authority.key, + seeds_slice, + ctoken_rent_sponsor.clone(), + ctoken_config.clone(), + Some(2), + None, + )?; + + let source = MultiInputTokenDataWithContext { + owner: token_data.token_data.owner, + amount: token_data.token_data.amount, + has_delegate: token_data.token_data.has_delegate, + delegate: token_data.token_data.delegate, + mint: token_data.token_data.mint, + version: token_data.token_data.version, + merkle_context: meta.tree_info.into(), + root_index: meta.tree_info.root_index, + }; + let decompress_index = crate::instructions::DecompressFullIndices { + source, + destination_index: owner_index, + }; + token_decompress_indices.push(decompress_index); + token_signers_seed_groups.push(ctoken_signer_seeds); + } + + let ctoken_ix = crate::instructions::decompress_full_ctoken_accounts_with_indices( + *fee_payer.key, + proof, + if has_pdas { + Some(*cpi_context.key) + } else { + None + }, + &token_decompress_indices, + packed_accounts, + ) + .map_err(ProgramError::from)?; + + let mut all_account_infos: Vec> = + Vec::with_capacity(1 + post_system_accounts.len() + 3); + all_account_infos.push(fee_payer.clone()); + all_account_infos.push(ctoken_cpi_authority.clone()); + all_account_infos.push(ctoken_program.clone()); + all_account_infos.push(ctoken_rent_sponsor.clone()); + all_account_infos.push(config.clone()); + all_account_infos.extend_from_slice(post_system_accounts); + + let signer_seed_refs: Vec> = token_signers_seed_groups + .iter() + .map(|group| group.iter().map(|s| s.as_slice()).collect()) + .collect(); + let signer_seed_slices: Vec<&[&[u8]]> = signer_seed_refs.iter().map(|g| g.as_slice()).collect(); + + solana_cpi::invoke_signed( + &ctoken_ix, + all_account_infos.as_slice(), + signer_seed_slices.as_slice(), + )?; + + Ok(()) +} diff --git a/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs b/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs index 0ddee8502a..ad3e2e99d3 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs @@ -340,7 +340,9 @@ pub fn compress_and_close_ctoken_accounts<'info>( }; // Determine rent recipient from extension or use default - let actual_rent_sponsor = if rent_sponsor_pubkey.is_none() { + let actual_rent_sponsor = if let Some(sponsor) = rent_sponsor_pubkey { + sponsor + } else { // Check if there's a rent recipient in the compressible extension if let Some(extensions) = &compressed_token.extensions { for extension in extensions { @@ -364,8 +366,6 @@ pub fn compress_and_close_ctoken_accounts<'info>( } } rent_sponsor_pubkey.ok_or(TokenSdkError::InvalidAccountData)? - } else { - rent_sponsor_pubkey.unwrap() }; let destination_pubkey = if with_compression_authority { @@ -408,6 +408,7 @@ pub fn compress_and_close_ctoken_accounts<'info>( /// subset of `remaining_accounts`. #[allow(clippy::too_many_arguments)] #[profile] +#[allow(clippy::extra_unused_lifetimes)] pub fn compress_and_close_ctoken_accounts_signed<'b, 'info>( token_accounts_to_compress: &[AccountInfoToCompress<'info>], fee_payer: AccountInfo<'info>, diff --git a/sdk-libs/compressed-token-sdk/src/lib.rs b/sdk-libs/compressed-token-sdk/src/lib.rs index c38cdd1255..8455f15f99 100644 --- a/sdk-libs/compressed-token-sdk/src/lib.rs +++ b/sdk-libs/compressed-token-sdk/src/lib.rs @@ -1,6 +1,7 @@ pub mod account; pub mod account2; pub mod ctoken; +pub mod decompress_runtime; pub mod error; pub mod instructions; pub mod pack; @@ -13,7 +14,7 @@ pub mod utils; use anchor_lang::{AnchorDeserialize, AnchorSerialize}; #[cfg(not(feature = "anchor"))] use borsh::{BorshDeserialize as AnchorDeserialize, BorshSerialize as AnchorSerialize}; -// Re-export +pub use decompress_runtime::{process_decompress_tokens_runtime, CTokenSeedProvider}; pub use light_compressed_token_types::*; pub use pack::{compat, Pack, Unpack}; pub use utils::{ diff --git a/sdk-libs/compressible-client/Cargo.toml b/sdk-libs/compressible-client/Cargo.toml index c368d3db35..39742bf1a4 100644 --- a/sdk-libs/compressible-client/Cargo.toml +++ b/sdk-libs/compressible-client/Cargo.toml @@ -16,6 +16,7 @@ solana-account = { workspace = true } light-client = { workspace = true, features = ["v2"] } light-sdk = { workspace = true, features = ["v2", "cpi-context"] } +light-compressed-account = { workspace = true, features = ["std"] } anchor-lang = { workspace = true, features = ["idl-build"], optional = true } borsh = { workspace = true } diff --git a/sdk-libs/compressible-client/src/lib.rs b/sdk-libs/compressible-client/src/lib.rs index 7a679d74b1..bad75d31cf 100644 --- a/sdk-libs/compressible-client/src/lib.rs +++ b/sdk-libs/compressible-client/src/lib.rs @@ -1,4 +1,8 @@ pub mod get_compressible_account; +pub mod load; + +// Re-export key types for convenience +pub use load::{load, load_simple, AccountToLoad, LoadError, LoadResult}; #[cfg(feature = "anchor")] use anchor_lang::{AnchorDeserialize, AnchorSerialize}; @@ -18,9 +22,19 @@ use solana_account::Account; use solana_instruction::{AccountMeta, Instruction}; use solana_pubkey::Pubkey; +/// Helper function to get the output queue from tree info. +/// Prefers next_tree_info.queue if available, otherwise uses current queue. +#[inline] +fn get_output_queue(tree_info: &TreeInfo) -> Pubkey { + tree_info + .next_tree_info + .as_ref() + .map(|next| next.queue) + .unwrap_or(tree_info.queue) +} + #[derive(AnchorSerialize, AnchorDeserialize)] pub struct InitializeCompressionConfigData { - pub compression_delay: u32, pub rent_sponsor: Pubkey, pub address_space: Vec, pub config_bump: u8, @@ -28,7 +42,6 @@ pub struct InitializeCompressionConfigData { #[derive(AnchorSerialize, AnchorDeserialize)] pub struct UpdateCompressionConfigData { - pub new_compression_delay: Option, pub new_rent_sponsor: Option, pub new_address_space: Option>, pub new_update_authority: Option, @@ -46,7 +59,6 @@ pub struct DecompressMultipleAccountsIdempotentData { pub struct CompressAccountsIdempotentData { pub proof: ValidityProof, pub compressed_accounts: Vec, - pub signer_seeds: Vec>>, pub system_accounts_offset: u8, } @@ -63,12 +75,10 @@ pub mod compressible_instruction { /// SHA256("global:decompress_accounts_idempotent")[..8] pub const DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR: [u8; 8] = [114, 67, 61, 123, 234, 31, 1, 112]; - /// SHA256("global:compress_token_account_ctoken_signer")[..8] - pub const COMPRESS_TOKEN_ACCOUNT_CTOKEN_SIGNER_DISCRIMINATOR: [u8; 8] = - [243, 154, 172, 243, 44, 214, 139, 73]; /// SHA256("global:compress_accounts_idempotent")[..8] pub const COMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR: [u8; 8] = - [89, 130, 165, 88, 12, 207, 178, 185]; + // [89, 130, 165, 88, 12, 207, 178, 185]; + [70, 236, 171, 120, 164, 93, 113, 181]; /// Creates an initialize_compression_config instruction #[allow(clippy::too_many_arguments)] @@ -77,7 +87,6 @@ pub mod compressible_instruction { discriminator: &[u8], payer: &Pubkey, authority: &Pubkey, - compression_delay: u32, rent_sponsor: Pubkey, address_space: Vec, config_bump: Option, @@ -101,7 +110,6 @@ pub mod compressible_instruction { ]; let instruction_data = InitializeCompressionConfigData { - compression_delay, rent_sponsor, address_space, config_bump, @@ -112,7 +120,7 @@ pub mod compressible_instruction { .try_to_vec() .expect("Failed to serialize instruction data"); - let mut data = Vec::new(); + let mut data = Vec::with_capacity(discriminator.len() + serialized_data.len()); data.extend_from_slice(discriminator); data.extend_from_slice(&serialized_data); @@ -128,7 +136,6 @@ pub mod compressible_instruction { program_id: &Pubkey, discriminator: &[u8], authority: &Pubkey, - new_compression_delay: Option, new_rent_sponsor: Option, new_address_space: Option>, new_update_authority: Option, @@ -141,7 +148,6 @@ pub mod compressible_instruction { ]; let instruction_data = UpdateCompressionConfigData { - new_compression_delay, new_rent_sponsor, new_address_space, new_update_authority, @@ -171,11 +177,14 @@ pub mod compressible_instruction { compressed_accounts: &[(CompressedAccount, T)], program_account_metas: &[AccountMeta], validity_proof_with_context: ValidityProofWithContext, - output_state_tree_info: TreeInfo, ) -> Result> where T: Pack + Clone + std::fmt::Debug, { + if compressed_accounts.is_empty() { + return Err("compressed_accounts cannot be empty".into()); + } + let mut remaining_accounts = PackedAccounts::default(); let mut has_tokens = false; @@ -212,8 +221,8 @@ pub mod compressible_instruction { } // pack output queue - let output_state_tree_index = - remaining_accounts.insert_or_get(output_state_tree_info.queue); + let output_queue = get_output_queue(&compressed_accounts[0].0.tree_info); + let output_state_tree_index = remaining_accounts.insert_or_get(output_queue); // pack tree infos let packed_tree_infos = @@ -222,37 +231,37 @@ pub mod compressible_instruction { let mut accounts = program_account_metas.to_vec(); // pack account data - let typed_compressed_accounts: Vec> = compressed_accounts - .iter() - .map(|(compressed_account, data)| { - let queue_index = - remaining_accounts.insert_or_get(compressed_account.tree_info.queue); - // create compressed_account_meta - let compressed_meta = CompressedAccountMetaNoLamportsNoAddress { - tree_info: packed_tree_infos - .state_trees - .as_ref() - .unwrap() - .packed_tree_infos - .iter() - .find(|pti| { - pti.queue_pubkey_index == queue_index - && pti.leaf_index == compressed_account.leaf_index - }) - .copied() - .ok_or( - "Matching PackedStateTreeInfo (queue_pubkey_index + leaf_index) not found", - )?, - output_state_tree_index, - }; + let packed_tree_infos_slice = &packed_tree_infos + .state_trees + .as_ref() + .unwrap() + .packed_tree_infos; - let packed_data = data.pack(&mut remaining_accounts); - Ok(CompressedAccountData { - meta: compressed_meta, - data: packed_data, + let mut typed_compressed_accounts = Vec::with_capacity(compressed_accounts.len()); + + for (compressed_account, data) in compressed_accounts { + let queue_index = remaining_accounts.insert_or_get(compressed_account.tree_info.queue); + + let tree_info = packed_tree_infos_slice + .iter() + .find(|pti| { + pti.queue_pubkey_index == queue_index + && pti.leaf_index == compressed_account.leaf_index }) - }) - .collect::, Box>>()?; + .copied() + .ok_or( + "Matching PackedStateTreeInfo (queue_pubkey_index + leaf_index) not found", + )?; + + let packed_data = data.pack(&mut remaining_accounts); + typed_compressed_accounts.push(CompressedAccountData { + meta: CompressedAccountMetaNoLamportsNoAddress { + tree_info, + output_state_tree_index, + }, + data: packed_data, + }); + } let (system_accounts, system_accounts_offset, _) = remaining_accounts.to_account_metas(); accounts.extend(system_accounts); @@ -268,7 +277,7 @@ pub mod compressible_instruction { }; let serialized_data = instruction_data.try_to_vec()?; - let mut data = Vec::new(); + let mut data = Vec::with_capacity(discriminator.len() + serialized_data.len()); data.extend_from_slice(discriminator); data.extend_from_slice(&serialized_data); @@ -279,7 +288,7 @@ pub mod compressible_instruction { }) } - /// Builds compress_accounts_idempotent instruction for PDAs and token accounts + /// Builds compress_accounts_idempotent instruction for PDAs only #[allow(clippy::too_many_arguments)] pub fn compress_accounts_idempotent( program_id: &Pubkey, @@ -287,50 +296,14 @@ pub mod compressible_instruction { account_pubkeys: &[Pubkey], accounts_to_compress: &[Account], program_account_metas: &[AccountMeta], - signer_seeds: Vec>>, validity_proof_with_context: ValidityProofWithContext, - output_state_tree_info: TreeInfo, ) -> Result> { if account_pubkeys.len() != accounts_to_compress.len() { return Err("Accounts pubkeys length must match accounts length".into()); } - println!( - "compress_accounts_idempotent - account_pubkeys: {:?}", - account_pubkeys - ); - // Sanity checks. - if !signer_seeds.is_empty() && signer_seeds.len() != accounts_to_compress.len() { - return Err("Signer seeds length must match accounts length or be empty".into()); - } - for (i, account) in account_pubkeys.iter().enumerate() { - if !signer_seeds.is_empty() { - let seeds = &signer_seeds[i]; - if !seeds.is_empty() { - let derived = Pubkey::create_program_address( - &seeds.iter().map(|v| v.as_slice()).collect::>(), - program_id, - ); - match derived { - Ok(derived_pubkey) => { - if derived_pubkey != *account { - return Err(format!( - "Derived PDA does not match account_to_compress at index {}: expected {}, got {:?}", - i, - account, - derived_pubkey - ).into()); - } - } - Err(e) => { - return Err(format!( - "Failed to derive PDA for account_to_compress at index {}: {}", - i, e - ) - .into()); - } - } - } - } + + if validity_proof_with_context.accounts.is_empty() { + return Err("validity_proof_with_context.accounts cannot be empty".into()); } let mut remaining_accounts = PackedAccounts::default(); @@ -338,8 +311,9 @@ pub mod compressible_instruction { let system_config = SystemAccountMetaConfig::new(*program_id); remaining_accounts.add_system_accounts_v2(system_config)?; - let output_state_tree_index = - remaining_accounts.insert_or_get(output_state_tree_info.queue); + // pack output queue - use first tree info from validity proof + let output_queue = get_output_queue(&validity_proof_with_context.accounts[0].tree_info); + let output_state_tree_index = remaining_accounts.insert_or_get(output_queue); let packed_tree_infos = validity_proof_with_context.pack_tree_infos(&mut remaining_accounts); @@ -373,12 +347,11 @@ pub mod compressible_instruction { let instruction_data = CompressAccountsIdempotentData { proof: validity_proof_with_context.proof, compressed_accounts: compressed_account_metas_no_lamports_no_address, - signer_seeds, system_accounts_offset: system_accounts_offset as u8, }; let serialized_data = instruction_data.try_to_vec()?; - let mut data = Vec::new(); + let mut data = Vec::with_capacity(discriminator.len() + serialized_data.len()); data.extend_from_slice(discriminator); data.extend_from_slice(&serialized_data); @@ -389,5 +362,3 @@ pub mod compressible_instruction { }) } } - -pub use compressible_instruction as CompressibleInstruction; diff --git a/sdk-libs/compressible-client/src/load.rs b/sdk-libs/compressible-client/src/load.rs new file mode 100644 index 0000000000..d1b92c75b3 --- /dev/null +++ b/sdk-libs/compressible-client/src/load.rs @@ -0,0 +1,285 @@ +use light_client::indexer::{Indexer, TreeInfo}; +use light_client::rpc::Rpc; +use light_compressed_account::compressed_account::CompressedAccountData; +use light_sdk::compressible::Pack; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; +use thiserror::Error; + +use crate::get_compressible_account::get_account_info_interface; +use crate::{compressible_instruction, AnchorDeserialize}; + +#[derive(Debug, Error)] +pub enum LoadError { + #[error("RPC error: {0}")] + Rpc(#[from] light_client::rpc::RpcError), + + #[error("Indexer error: {0}")] + Indexer(#[from] light_client::indexer::IndexerError), + + #[error("Failed to build decompress instruction: {0}")] + InstructionBuild(#[from] Box), + + #[error("Account {0} not found")] + AccountNotFound(Pubkey), + + #[error("Failed to deserialize account data")] + Deserialization, + + #[error("Compressible account error: {0}")] + CompressibleAccount(#[from] crate::get_compressible_account::CompressibleAccountError), +} + +/// Input specification for a compressible account to be loaded. +/// +/// Each account can be either a PDA or a token account, and will be checked +/// for compression state. If compressed, it will be included in the decompress +/// instruction. +#[derive(Debug, Clone)] +pub struct AccountToLoad { + /// The address of the account (decompressed/target address) + pub address: Pubkey, + + /// The owner program ID for deriving the compressed address + pub program_id: Pubkey, + + /// Tree info for deriving the compressed address + pub address_tree_info: TreeInfo, +} + +/// Result of loading accounts, containing both the optional decompress instruction +/// and metadata about which accounts were compressed. +#[derive(Debug)] +pub struct LoadResult { + /// The decompress instruction if any accounts need decompression. + /// None if all accounts are already decompressed (hot state). + pub instruction: Option, + + /// Metadata about which accounts were found to be compressed + pub compressed_accounts: Vec, +} + +/// Loads compressible accounts by checking their compression state and building +/// a decompress instruction if needed. +/// +/// This is the Rust equivalent of the TypeScript `decompressIfNeeded()` method. +/// It provides a simple interface where clients can pass their accounts without +/// needing to know the internals of compression/decompression. +/// +/// # Design Goals +/// +/// 1. **Simple Interface**: Clients pass account addresses and get back an instruction (or None) +/// 2. **Automatic Detection**: Automatically determines which accounts need decompression +/// 3. **Idempotent**: Returns None if all accounts are already decompressed +/// 4. **Composable**: Returns a standalone instruction that can be prepended to transactions +/// +/// # Arguments +/// +/// * `program_id` - The program ID that owns the compressible accounts +/// * `discriminator` - The instruction discriminator for decompress_accounts_idempotent +/// * `accounts_to_load` - List of accounts to check and potentially decompress +/// * `program_account_metas` - Additional account metas required by the program's decompress instruction +/// * `rpc` - RPC client for querying account state +/// +/// # Returns +/// +/// Returns `Ok(LoadResult)` where: +/// - `instruction` is `Some` if decompression is needed +/// - `instruction` is `None` if all accounts are already decompressed +/// +/// # Example +/// +/// ```ignore +/// use light_compressible_client::{load, AccountToLoad, LoadResult}; +/// use light_client::indexer::get_default_address_tree_info; +/// +/// // Define accounts to load +/// let accounts_to_load = vec![ +/// AccountToLoad { +/// address: pool_state_address, +/// program_id: my_program_id, +/// address_tree_info: get_default_address_tree_info(), +/// }, +/// AccountToLoad { +/// address: user_account_address, +/// program_id: my_program_id, +/// address_tree_info: get_default_address_tree_info(), +/// }, +/// ]; +/// +/// // Program-specific accounts required for decompress instruction +/// let program_metas = vec![ +/// AccountMeta::new_readonly(fee_payer, true), +/// AccountMeta::new_readonly(config, false), +/// // ... other required accounts +/// ]; +/// +/// // Load accounts (checks compression state and builds instruction if needed) +/// let result = load( +/// my_program_id, +/// &DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, +/// accounts_to_load, +/// &program_metas, +/// &mut rpc_client, +/// ).await?; +/// +/// // If decompression is needed, prepend the instruction +/// if let Some(decompress_ix) = result.instruction { +/// transaction.add_instruction(decompress_ix); +/// } +/// ``` +pub async fn load( + program_id: Pubkey, + discriminator: &[u8], + accounts_to_load: Vec, + program_account_metas: &[AccountMeta], + rpc: &mut R, +) -> Result +where + T: Pack + Clone + std::fmt::Debug + AnchorDeserialize, + R: Rpc + Indexer, +{ + if accounts_to_load.is_empty() { + return Ok(LoadResult { + instruction: None, + compressed_accounts: Vec::new(), + }); + } + + // Step 1: Fetch account info for all accounts to check compression state + let mut account_interfaces = Vec::with_capacity(accounts_to_load.len()); + + for account_spec in &accounts_to_load { + let account_info = get_account_info_interface( + &account_spec.address, + &account_spec.program_id, + &account_spec.address_tree_info, + rpc, + ) + .await?; + + if let Some(info) = account_info { + account_interfaces.push((account_spec.clone(), info)); + } else { + return Err(LoadError::AccountNotFound(account_spec.address)); + } + } + + // Step 2: Filter to only compressed accounts + let compressed_accounts_data: Vec<_> = account_interfaces + .iter() + .filter(|(_, info)| info.is_compressed) + .collect(); + + if compressed_accounts_data.is_empty() { + // All accounts are already decompressed (hot state) - no instruction needed + return Ok(LoadResult { + instruction: None, + compressed_accounts: Vec::new(), + }); + } + + // Step 3: Build validity proof inputs + let hashes: Vec<[u8; 32]> = compressed_accounts_data + .iter() + .filter_map(|(_, info)| info.merkle_context.as_ref().map(|ctx| ctx.hash)) + .collect(); + + // Get validity proof from indexer + let validity_proof_response = rpc + .get_validity_proof(hashes.clone(), Vec::new(), None) + .await?; + + let validity_proof_with_context = validity_proof_response.value; + + // Step 4: Prepare data for decompress instruction builder + let mut compressed_accounts_with_data = Vec::new(); + let mut decompressed_addresses = Vec::new(); + + for (account_spec, info) in &account_interfaces { + if !info.is_compressed { + continue; + } + + let merkle_context = info + .merkle_context + .as_ref() + .ok_or_else(|| LoadError::Deserialization)?; + + // Build CompressedAccount from the account info + let compressed_account = light_client::indexer::CompressedAccount { + address: None, // PDAs don't have addresses in the compressed state + data: if !info.account_info.data.is_empty() { + Some(CompressedAccountData { + discriminator: info.account_info.data[..8] + .try_into() + .map_err(|_| LoadError::Deserialization)?, + data: info.account_info.data[8..].to_vec(), + data_hash: [0u8; 32], // Will be computed by the system + }) + } else { + None + }, + hash: merkle_context.hash, + lamports: info.account_info.lamports, + leaf_index: merkle_context.leaf_index, + owner: info.account_info.owner, + prove_by_index: merkle_context.prove_by_index, + seq: None, + slot_created: 0, + tree_info: merkle_context.tree_info.clone(), + }; + + // Deserialize the account data to get the typed data + let typed_data = T::deserialize(&mut &info.account_info.data[8..]) + .map_err(|_| LoadError::Deserialization)?; + + compressed_accounts_with_data.push((compressed_account, typed_data)); + decompressed_addresses.push(account_spec.address); + } + + // Step 5: Build the decompress instruction + let decompress_instruction = compressible_instruction::decompress_accounts_idempotent( + &program_id, + discriminator, + &decompressed_addresses, + &compressed_accounts_with_data, + program_account_metas, + validity_proof_with_context, + )?; + + Ok(LoadResult { + instruction: Some(decompress_instruction), + compressed_accounts: decompressed_addresses, + }) +} + +/// Simplified version of `load` for cases where all accounts share the same +/// program_id and address_tree_info. +/// +/// This is a convenience wrapper that constructs `AccountToLoad` specs from +/// simple addresses. +pub async fn load_simple( + program_id: Pubkey, + discriminator: &[u8], + account_addresses: Vec, + address_tree_info: TreeInfo, + program_account_metas: &[AccountMeta], + rpc: &mut R, +) -> Result +where + T: Pack + Clone + std::fmt::Debug + AnchorDeserialize, + R: Rpc + Indexer, +{ + let accounts_to_load = account_addresses + .into_iter() + .map(|address| AccountToLoad { + address, + program_id, + address_tree_info: address_tree_info.clone(), + }) + .collect(); + + load::(program_id, discriminator, accounts_to_load, program_account_metas, rpc).await +} + diff --git a/sdk-libs/macros/src/compressible/GUIDE.md b/sdk-libs/macros/src/compressible/GUIDE.md new file mode 100644 index 0000000000..4b95bed125 --- /dev/null +++ b/sdk-libs/macros/src/compressible/GUIDE.md @@ -0,0 +1,199 @@ +## Compressible macros — caller program usage (first draft) + +Use this to add rent-free PDAs, cTokens, and cMints to your program with minimal boilerplate. + +### What you get (the interface) + +- `#[derive(Compressible)]`: makes a struct compressible. Expect a `compression_info: Option` field. +- `#[add_compressible_instructions(...)]`: generates ready-to-use `decompress_accounts_idempotent` and `compress_accounts_idempotent` entrypoints, PDA seed derivation, and optional cToken integration. +- `#[account]`: convenience macro for Anchor accounts adding `LightHasher` + `LightDiscriminator` derives. +- Rent tools: `derive_light_rent_sponsor_pda!`, `derive_light_rent_sponsor!` for compile‑time rent sponsor constants. +- Program config helpers: `process_initialize_compression_config_checked`, `process_update_compression_config`. + +### How to use — PDA only + +1. Define your PDAs + +```rust +use light_sdk::compressible::CompressionInfo; +use light_sdk_macros::{account, Compressible}; + +#[account] +#[derive(Compressible)] +pub struct UserRecord { + #[skip] + pub compression_info: Option, + pub owner: Pubkey, + pub name: String, + pub score: u64, +} +``` + +2. Generate compress/decompress instructions with auto seeds + +```rust +use light_sdk_macros::add_compressible_instructions; + +#[add_compressible_instructions( + UserRecord = ("user_record", data.owner.as_ref()) +)] +#[program] +pub mod my_program {} +``` + +3. Initialize your compression config (one-time) + +- Call the generated `initialize_compression_config` entrypoint or invoke: + - `process_initialize_compression_config_checked(config_pda, update_authority, program_data, rent_sponsor, compression_authority, rent_config, write_top_up, address_space, bump=0, payer, system_program, program_id)` +- Inputs you must pick: + - rent_sponsor: who receives rent when PDAs compress/close + - compression_authority: who can compress/close your PDAs + - rent_config + write_top_up: rent curve + write top‑up per write + - address_space: one address tree pubkey for your PDAs + +4. Use the generated entrypoints + +- `decompress_accounts_idempotent(...)` +- `compress_accounts_idempotent(...)` + +### How to use — mixed with cToken + +1. Extend the macro with token variants + +```rust +#[add_compressible_instructions( + // PDAs + UserRecord = ("user_record", data.owner.as_ref()), + // Program‑owned ctoken PDA (must provide authority seeds) + TreasuryCtoken = (is_token, "treasury_ctoken", ctx.fee_payer, authority = (ctx.treasury)), + // User ATA variant (no seeds, derived from owner+mint) + UserAta = (is_token, is_ata) +)] +#[program] +pub mod my_program {} +``` + +2. Create compressible token accounts (ATAs) on the client or via CPI + +- Inputs (client builder): `CreateCompressibleAssociatedTokenAccountInputs { payer, owner, mint, compressible_config, rent_sponsor, pre_pay_num_epochs, lamports_per_write, token_account_version }` +- Authority-less user ATAs use `derive_ctoken_ata(owner, mint)` under the hood. + +3. Decompress/compress flows + +- The generated `decompress_accounts_idempotent` and `compress_accounts_idempotent` accept packed token data alongside your PDAs. You only provide the standard accounts the macro adds (fee_payer, config, rent_sponsor, and optional ctoken config/cpi auth). + +### How to use — cMints (compressed mints) + +- Create a compressed mint: + - `create_compressed_mint(CreateCompressedMintInputs { decimals, mint_authority, freeze_authority, proof, address_merkle_tree_root_index, mint_signer, payer, address_tree_pubkey, output_queue, extensions, version })` + - Derive addresses with: + - `derive_compressed_mint_address(&mint_signer, &address_tree_pubkey)` + - `find_spl_mint_address(&mint_signer)` +- Mint tokens to compressed accounts: + - `create_mint_to_compressed_instruction(MintToCompressedInputs { compressed_mint_inputs, recipients, mint_authority, payer, state_merkle_tree, input_queue, output_queue_cmint, output_queue_tokens, decompressed_mint_config, proof, token_account_version, cpi_context_pubkey, token_pool })` + +Keep it simple: create cMint → mint to recipients (compressed accounts or cToken ATAs) using the SDK helpers below. + +### cToken SDK (compressed-token-sdk) — the interfaces you actually call + +- Accounts + - `derive_ctoken_ata(owner, mint) -> (Pubkey, u8)` + - `create_compressible_associated_token_account(inputs)` / `_idempotent` (+ “2” variants if owner/mint passed as accounts) + - Low-level: `create_compressible_token_account_instruction(CreateCompressibleTokenAccount)` +- Mints + - `create_compressed_mint(CreateCompressedMintInputs)` + - `derive_compressed_mint_address(mint_seed, address_tree)` + - `find_spl_mint_address(mint_seed)` +- Mint to recipients + - `create_mint_to_compressed_instruction(MintToCompressedInputs)` + - Types: `Recipient { recipient, amount }` +- Transfer SPL ↔ cToken + - `create_transfer_spl_to_ctoken_instruction(...)` + - `create_transfer_ctoken_to_spl_instruction(...)` + - `transfer_interface(...)` / `transfer_interface_signed(...)` +- Update compressed mint + - `update_compressed_mint(UpdateCompressedMintInputs)` + +### Rent — set/update for your PDAs and for cTokens + +- PDAs (your program) + - One-time config: `process_initialize_compression_config_checked(...)` (or use generated `initialize_compression_config` entrypoint) + - Update later: `process_update_compression_config(config, authority, new_update_authority?, new_rent_sponsor?, new_compression_authority?, new_rent_config?, new_write_top_up?, new_address_space?, program_id)` + - Use `light_compressible::rent::RentConfig` to define rent curve and distribution. Funds on close/compress go to `rent_sponsor` (completed epochs) and refund fee payer for partial epochs automatically. +- cTokens (account-level) + - When creating a compressible token account, you pass: + - `rent_sponsor`, `pre_pay_num_epochs`, optional `lamports_per_write`, and `compressible_config` (the registry’s or your chosen config PDA) + - For ATAs: `CreateCompressibleAssociatedTokenAccountInputs { ... }` + +### Rust client — the minimum you need + +1. Connect and fetch proofs + +```rust +use light_client::rpc::{LightClient, LightClientConfig, Rpc}; + +let mut rpc = LightClient::new(LightClientConfig::local()).await?; // or devnet/mainnet +// rpc.get_validity_proof(account_hashes, new_addresses, None).await? +``` + +2. Create a compressible ATA + +```rust +use light_compressed_token_sdk::instructions::{ + create_compressible_associated_token_account, CreateCompressibleAssociatedTokenAccountInputs +}; + +let ix = create_compressible_associated_token_account(CreateCompressibleAssociatedTokenAccountInputs { + payer, + owner, + mint, + compressible_config, + rent_sponsor, + pre_pay_num_epochs: 2, + lamports_per_write: Some(1_000), + token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, +})?; +``` + +3. Create a cMint and mint to recipients + +```rust +use light_compressed_token_sdk::instructions::{ + create_compressed_mint, CreateCompressedMintInputs, + create_mint_to_compressed_instruction, MintToCompressedInputs +}; +use light_ctoken_types::instructions::mint_action::Recipient; + +let create_cmint_ix = create_compressed_mint(CreateCompressedMintInputs { /* fill from RPC + keys */ })?; +let mint_ix = create_mint_to_compressed_instruction(MintToCompressedInputs { + recipients: vec![Recipient { recipient: some_address, amount: 1000 }], + /* queues/tree/authority from RPC + keys */ +}, None)?; +``` + +4. High-level helpers (token-client) + +```rust +use light_token_client::actions::{create_compressible_token_account, CreateCompressibleTokenAccountInputs, mint_to_compressed}; + +let token_acc = create_compressible_token_account(&mut rpc, CreateCompressibleTokenAccountInputs { + owner, mint, num_prepaid_epochs: 2, payer: &payer, token_account_keypair: None, + lamports_per_write: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat +}).await?; + +let sig = mint_to_compressed(&mut rpc, spl_mint_pda, vec![Recipient{ recipient: token_acc, amount: 1000 }], light_ctoken_types::state::TokenDataVersion::ShaFlat, &mint_authority, &payer).await?; +``` + +### TL;DR checklists + +- PDA only + - Add `#[derive(Compressible)]` + `compression_info` + - Add `#[add_compressible_instructions(...)]` with seeds + - Initialize config (rent_sponsor, compression_authority, rent_config, write_top_up, address_space) + - Call generated compress/decompress entrypoints +- Mixed with cToken + - Add token variants in `#[add_compressible_instructions(...)]` (program-owned with `authority = (...)` or `is_ata`) + - Use SDK to create cToken ATAs; pass rent fields + - Mint via cMints and `mint_to_compressed` or `mint_action` +- cMints + - `create_compressed_mint(...)` then `create_mint_to_compressed_instruction(...)` diff --git a/sdk-libs/macros/src/compressible/README.md b/sdk-libs/macros/src/compressible/README.md new file mode 100644 index 0000000000..eb7337d103 --- /dev/null +++ b/sdk-libs/macros/src/compressible/README.md @@ -0,0 +1,45 @@ +# Compressible Macros + +Procedural macros for generating rent-free account types and their hooks for Solana programs. + +## Files + +**`mod.rs`** - Module declaration + +**`traits.rs`** - Core trait implementations + +- `HasCompressionInfo` - CompressionInfo field access +- `CompressAs` - Field-level compression control +- `Compressible` - Full trait bundle (Size + HasCompressionInfo + CompressAs) + +**`pack_unpack.rs`** - Pubkey compression + +- Generates `PackedXxx` structs where Pubkey fields become u8 indices +- Implements Pack/Unpack traits for serialization efficiency + +**`variant_enum.rs`** - Account variant enum + +- Generates `CompressedAccountVariant` enum from account types +- Implements all required traits (Default, DataHasher, Size, Pack, Unpack) +- Creates `CompressedAccountData` wrapper struct + +**`instructions.rs`** - Instruction generation + +- Main macro: `add_compressible_instructions` +- Generates compress/decompress instruction handlers +- Creates context structs and account validation +- **Compress**: PDA-only (ctokens compressed via registry) +- **Decompress**: Full PDA + ctoken support + +**`seed_providers.rs`** - Seed derivation + +- PDA seed provider implementations +- CToken seed provider with account/authority derivation +- Client-side seed functions for off-chain use + +**`decompress_context.rs`** - Decompression trait + +- Generates `DecompressContext` implementation +- Account accessor methods +- PDA/token separation logic +- Token processing delegation diff --git a/sdk-libs/macros/src/compressible/decompress_context.rs b/sdk-libs/macros/src/compressible/decompress_context.rs new file mode 100644 index 0000000000..e6ae4c2157 --- /dev/null +++ b/sdk-libs/macros/src/compressible/decompress_context.rs @@ -0,0 +1,225 @@ +//! DecompressContext trait generation. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + DeriveInput, Ident, Result, Token, +}; + +struct PdaTypesAttr { + types: Punctuated, +} + +impl Parse for PdaTypesAttr { + fn parse(input: ParseStream) -> Result { + Ok(PdaTypesAttr { + types: Punctuated::parse_terminated(input)?, + }) + } +} + +struct TokenVariantAttr { + variant: Ident, +} + +impl Parse for TokenVariantAttr { + fn parse(input: ParseStream) -> Result { + Ok(TokenVariantAttr { + variant: input.parse()?, + }) + } +} + +pub fn generate_decompress_context_trait_impl( + pda_type_idents: Vec, + token_variant_ident: Ident, + lifetime: syn::Lifetime, +) -> Result { + let pda_match_arms: Vec<_> = pda_type_idents + .iter() + .map(|pda_type| { + let packed_name = format_ident!("Packed{}", pda_type); + quote! { + CompressedAccountVariant::#packed_name(packed) => { + match light_sdk::compressible::handle_packed_pda_variant::<#pda_type, #packed_name, _, _>( + &*self.rent_sponsor, + cpi_accounts, + address_space, + &solana_accounts[i], + i, + &packed, + &meta, + post_system_accounts, + &mut compressed_pda_infos, + &program_id, + self, // Pass the context itself as seed_accounts + std::option::Option::Some(seed_params_ref), + ) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(e) => return std::result::Result::Err(e), + } + } + CompressedAccountVariant::#pda_type(_) => { + unreachable!("Unpacked variants should not be present during decompression"); + } + } + }) + .collect(); + + Ok(quote! { + impl<#lifetime> light_sdk::compressible::DecompressContext<#lifetime> for DecompressAccountsIdempotent<#lifetime> { + type CompressedData = CompressedAccountData; + type PackedTokenData = light_compressed_token_sdk::compat::PackedCTokenData<#token_variant_ident>; + type CompressedMeta = light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; + type SeedParams = SeedParams; + + fn fee_payer(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &*self.fee_payer + } + + fn config(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.config + } + + fn rent_sponsor(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.rent_sponsor + } + + fn ctoken_rent_sponsor(&self) -> std::option::Option<&solana_account_info::AccountInfo<#lifetime>> { + self.ctoken_rent_sponsor.as_ref() + } + + fn ctoken_program(&self) -> std::option::Option<&solana_account_info::AccountInfo<#lifetime>> { + self.ctoken_program.as_ref().map(|a| &**a) + } + + fn ctoken_cpi_authority(&self) -> std::option::Option<&solana_account_info::AccountInfo<#lifetime>> { + self.ctoken_cpi_authority.as_ref().map(|a| &**a) + } + + fn ctoken_config(&self) -> std::option::Option<&solana_account_info::AccountInfo<#lifetime>> { + self.ctoken_config.as_ref().map(|a| &**a) + } + + fn collect_pda_and_token<'b>( + &self, + cpi_accounts: &light_sdk::cpi::v2::CpiAccounts<'b, #lifetime>, + address_space: solana_pubkey::Pubkey, + compressed_accounts: Vec, + solana_accounts: &[solana_account_info::AccountInfo<#lifetime>], + seed_params: std::option::Option<&Self::SeedParams>, + ) -> std::result::Result<( + Vec, + Vec<(Self::PackedTokenData, Self::CompressedMeta)>, + ), solana_program_error::ProgramError> { + let post_system_offset = cpi_accounts.system_accounts_end_offset(); + let all_infos = cpi_accounts.account_infos(); + let post_system_accounts = &all_infos[post_system_offset..]; + let program_id = &crate::ID; + + let mut compressed_pda_infos = Vec::with_capacity(compressed_accounts.len()); + let mut compressed_token_accounts = Vec::with_capacity(compressed_accounts.len()); + + // Get seed_params or use default + let seed_params_ref = match seed_params { + std::option::Option::Some(params) => params, + std::option::Option::None => &SeedParams::default(), + }; + + for (i, compressed_data) in compressed_accounts.into_iter().enumerate() { + let meta = compressed_data.meta; + match compressed_data.data { + #(#pda_match_arms)* + CompressedAccountVariant::PackedCTokenData(mut data) => { + data.token_data.version = 3; + compressed_token_accounts.push((data, meta)); + } + CompressedAccountVariant::CTokenData(_) => { + unreachable!(); + } + } + } + + std::result::Result::Ok((compressed_pda_infos, compressed_token_accounts)) + } + + #[inline(never)] + #[allow(clippy::too_many_arguments)] + fn process_tokens<'b>( + &self, + remaining_accounts: &[solana_account_info::AccountInfo<#lifetime>], + fee_payer: &solana_account_info::AccountInfo<#lifetime>, + ctoken_program: &solana_account_info::AccountInfo<#lifetime>, + ctoken_rent_sponsor: &solana_account_info::AccountInfo<#lifetime>, + ctoken_cpi_authority: &solana_account_info::AccountInfo<#lifetime>, + ctoken_config: &solana_account_info::AccountInfo<#lifetime>, + config: &solana_account_info::AccountInfo<#lifetime>, + ctoken_accounts: Vec<(Self::PackedTokenData, Self::CompressedMeta)>, + proof: light_sdk::instruction::ValidityProof, + cpi_accounts: &light_sdk::cpi::v2::CpiAccounts<'b, #lifetime>, + post_system_accounts: &[solana_account_info::AccountInfo<#lifetime>], + has_pdas: bool, + ) -> std::result::Result<(), solana_program_error::ProgramError> { + light_compressed_token_sdk::decompress_runtime::process_decompress_tokens_runtime( + self, + remaining_accounts, + fee_payer, + ctoken_program, + ctoken_rent_sponsor, + ctoken_cpi_authority, + ctoken_config, + config, + ctoken_accounts, + proof, + cpi_accounts, + post_system_accounts, + has_pdas, + &crate::ID, + ) + } + } + }) +} + +pub fn derive_decompress_context(input: DeriveInput) -> Result { + let pda_types_attr = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("pda_types")) + .ok_or_else(|| { + syn::Error::new_spanned( + &input, + "DecompressContext derive requires #[pda_types(Type1, Type2, ...)] attribute", + ) + })?; + + let pda_types: PdaTypesAttr = pda_types_attr.parse_args()?; + let pda_type_idents: Vec = pda_types.types.iter().cloned().collect(); + + let token_variant_attr = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("token_variant")) + .ok_or_else(|| { + syn::Error::new_spanned( + &input, + "DecompressContext derive requires #[token_variant(CTokenAccountVariant)] attribute", + ) + })?; + + let token_variant: TokenVariantAttr = token_variant_attr.parse_args()?; + let token_variant_ident = token_variant.variant; + + let lifetime = if let Some(lt) = input.generics.lifetimes().next() { + lt.lifetime.clone() + } else { + return Err(syn::Error::new_spanned( + &input, + "DecompressContext requires a lifetime parameter (e.g., <'info>)", + )); + }; + + generate_decompress_context_trait_impl(pda_type_idents, token_variant_ident, lifetime) +} diff --git a/sdk-libs/macros/src/compressible/instructions.rs b/sdk-libs/macros/src/compressible/instructions.rs new file mode 100644 index 0000000000..ad03f03476 --- /dev/null +++ b/sdk-libs/macros/src/compressible/instructions.rs @@ -0,0 +1,1572 @@ +//! Compressible instructions generation. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + Expr, Ident, Item, ItemMod, LitStr, Result, Token, +}; + +macro_rules! macro_error { + ($span:expr, $msg:expr) => { + syn::Error::new_spanned( + $span, + format!( + "{}\n --> macro location: {}:{}", + $msg, + file!(), + line!() + ) + ) + }; + ($span:expr, $fmt:expr, $($arg:tt)*) => { + syn::Error::new_spanned( + $span, + format!( + concat!($fmt, "\n --> macro location: {}:{}"), + $($arg)*, + file!(), + line!() + ) + ) + }; +} + +#[derive(Debug, Clone, Copy)] +pub enum InstructionVariant { + PdaOnly, + TokenOnly, + Mixed, +} + +#[derive(Clone)] +pub struct TokenSeedSpec { + pub variant: Ident, + pub _eq: Token![=], + pub is_token: Option, + pub is_ata: bool, + pub seeds: Punctuated, + pub authority: Option>, +} + +impl Parse for TokenSeedSpec { + fn parse(input: ParseStream) -> Result { + let variant = input.parse()?; + let _eq = input.parse()?; + + let content; + syn::parenthesized!(content in input); + + let (is_token, is_ata, seeds, authority) = if content.peek(Ident) { + let first_ident: Ident = content.parse()?; + + match first_ident.to_string().as_str() { + "is_token" => { + let _comma: Token![,] = content.parse()?; + + if content.peek(Ident) { + let fork = content.fork(); + if let Ok(second_ident) = fork.parse::() { + if second_ident == "is_ata" { + let _: Ident = content.parse()?; + return Ok(TokenSeedSpec { + variant, + _eq, + is_token: Some(true), + is_ata: true, + seeds: Punctuated::new(), + authority: None, + }); + } + } + } + + let (seeds, authority) = parse_seeds_with_authority(&content)?; + (Some(true), false, seeds, authority) + } + "true" => { + let _comma: Token![,] = content.parse()?; + let (seeds, authority) = parse_seeds_with_authority(&content)?; + (Some(true), false, seeds, authority) + } + "is_pda" | "false" => { + let _comma: Token![,] = content.parse()?; + let (seeds, authority) = parse_seeds_with_authority(&content)?; + (Some(false), false, seeds, authority) + } + _ => { + let mut seeds = Punctuated::new(); + // Allow function-call expressions starting with an identifier, e.g. max_key(...) + if content.peek(syn::token::Paren) { + let args_tokens; + syn::parenthesized!(args_tokens in content); + let inner_ts: proc_macro2::TokenStream = args_tokens.parse()?; + let call_expr: syn::Expr = + syn::parse2(quote! { #first_ident( #inner_ts ) })?; + seeds.push(SeedElement::Expression(Box::new(call_expr))); + } else { + seeds.push(SeedElement::Expression(Box::new(syn::Expr::Path( + syn::ExprPath { + attrs: vec![], + qself: None, + path: syn::Path::from(first_ident), + }, + )))); + } + + if content.peek(Token![,]) { + let _comma: Token![,] = content.parse()?; + let (rest, authority) = parse_seeds_with_authority(&content)?; + seeds.extend(rest); + (None, false, seeds, authority) + } else { + (None, false, seeds, None) + } + } + } + } else { + let (seeds, authority) = parse_seeds_with_authority(&content)?; + (None, false, seeds, authority) + }; + + Ok(TokenSeedSpec { + variant, + _eq, + is_token, + is_ata, + seeds, + authority, + }) + } +} + +#[allow(clippy::type_complexity)] +fn parse_seeds_with_authority( + content: ParseStream, +) -> Result<(Punctuated, Option>)> { + let mut seeds = Punctuated::new(); + let mut authority = None; + + while !content.is_empty() { + if content.peek(Ident) { + let fork = content.fork(); + if let Ok(ident) = fork.parse::() { + if ident == "authority" && fork.peek(Token![=]) { + let _: Ident = content.parse()?; + let _: Token![=] = content.parse()?; + + if content.peek(syn::token::Paren) { + let auth_content; + syn::parenthesized!(auth_content in content); + let mut auth_seeds = Vec::new(); + + while !auth_content.is_empty() { + auth_seeds.push(auth_content.parse::()?); + if auth_content.peek(Token![,]) { + let _: Token![,] = auth_content.parse()?; + } else { + break; + } + } + authority = Some(auth_seeds); + } else { + authority = Some(vec![content.parse::()?]); + } + + if content.peek(Token![,]) { + let _: Token![,] = content.parse()?; + continue; + } else { + break; + } + } + } + } + + seeds.push(content.parse::()?); + + if content.peek(Token![,]) { + let _: Token![,] = content.parse()?; + if content.is_empty() { + break; + } + } else { + break; + } + } + + Ok((seeds, authority)) +} + +#[derive(Clone)] +pub enum SeedElement { + Literal(LitStr), + Expression(Box), +} + +impl Parse for SeedElement { + fn parse(input: ParseStream) -> Result { + if input.peek(LitStr) { + Ok(SeedElement::Literal(input.parse()?)) + } else { + Ok(SeedElement::Expression(input.parse()?)) + } + } +} + +pub struct InstructionDataSpec { + pub field_name: Ident, + pub field_type: syn::Type, +} + +impl Parse for InstructionDataSpec { + fn parse(input: ParseStream) -> Result { + let field_name: Ident = input.parse()?; + let _eq: Token![=] = input.parse()?; + let field_type: syn::Type = input.parse()?; + + Ok(InstructionDataSpec { + field_name, + field_type, + }) + } +} + +struct EnhancedMacroArgs { + account_types: Vec, + pda_seeds: Vec, + token_seeds: Vec, + instruction_data: Vec, +} + +impl Parse for EnhancedMacroArgs { + fn parse(input: ParseStream) -> Result { + let mut account_types = Vec::new(); + let mut pda_seeds = Vec::new(); + let mut token_seeds = Vec::new(); + let mut instruction_data = Vec::new(); + + let mut _item_count = 0; + while !input.is_empty() { + let ident: Ident = input.parse()?; + + if input.peek(Token![=]) { + let _eq: Token![=] = input.parse()?; + + if input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in input); + let inside: TokenStream = content.parse()?; + let seed_spec: TokenSeedSpec = syn::parse2(quote! { #ident = (#inside) })?; + + let is_token_account = seed_spec.is_token.unwrap_or(false); + if is_token_account { + token_seeds.push(seed_spec); + } else { + pda_seeds.push(seed_spec); + account_types.push(ident); + } + } else { + let field_type: syn::Type = input.parse()?; + instruction_data.push(InstructionDataSpec { + field_name: ident, + field_type, + }); + } + } else { + account_types.push(ident); + } + + if input.peek(Token![,]) { + let _comma: Token![,] = input.parse()?; + } else { + break; + } + _item_count += 1; + } + Ok(EnhancedMacroArgs { + account_types, + pda_seeds, + token_seeds, + instruction_data, + }) + } +} + +#[allow(clippy::too_many_arguments)] +#[inline(never)] +pub fn add_compressible_instructions( + args: TokenStream, + mut module: ItemMod, +) -> Result { + let enhanced_args = match syn::parse2::(args.clone()) { + Ok(args) => args, + Err(e) => { + eprintln!("ERROR: Failed to parse macro args: {}", e); + eprintln!("Args were: {}", args); + return Err(e); + } + }; + + let account_types = enhanced_args.account_types; + let pda_seeds = Some(enhanced_args.pda_seeds); + let token_seeds = Some(enhanced_args.token_seeds); + let instruction_data = enhanced_args.instruction_data; + + if module.content.is_none() { + return Err(macro_error!(&module, "Module must have a body")); + } + + if account_types.is_empty() { + return Err(macro_error!( + &module, + "At least one account type must be specified" + )); + } + + let size_validation_checks = validate_compressed_account_sizes(&account_types)?; + + let content = module.content.as_mut().unwrap(); + + let ctoken_enum = if let Some(ref token_seed_specs) = token_seeds { + if !token_seed_specs.is_empty() { + crate::compressible::seed_providers::generate_ctoken_account_variant_enum( + token_seed_specs, + )? + } else { + crate::compressible::utils::generate_empty_ctoken_enum() + } + } else { + crate::compressible::utils::generate_empty_ctoken_enum() + }; + + if let Some(ref token_seed_specs) = token_seeds { + for spec in token_seed_specs { + if spec.is_ata { + if !spec.seeds.is_empty() { + return Err(macro_error!( + &spec.variant, + "ATA variant '{}' must not have seeds - ATAs are derived from owner+mint only", + spec.variant + )); + } + if spec.authority.is_some() { + return Err(macro_error!( + &spec.variant, + "ATA variant '{}' must not have authority - ATAs are owned by user wallets", + spec.variant + )); + } + } else if spec.authority.is_none() { + return Err(macro_error!( + &spec.variant, + "Program-owned token account '{}' must specify authority = for compression signing. For user-owned ATAs, use is_ata flag instead.", + spec.variant + )); + } + } + } + + let mut account_types_stream = TokenStream::new(); + for (i, account_type) in account_types.iter().enumerate() { + if i > 0 { + account_types_stream.extend(quote! { , }); + } + account_types_stream.extend(quote! { #account_type }); + } + let enum_and_traits = + crate::compressible::variant_enum::compressed_account_variant(account_types_stream)?; + + // Generate SeedParams struct for instruction data fields + let seed_params_struct = { + let param_fields: Vec<_> = instruction_data + .iter() + .map(|spec| { + let field_name = &spec.field_name; + let field_type = &spec.field_type; + quote! { + pub #field_name: #field_type + } + }) + .collect(); + + quote! { + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize, Clone, Debug, Default)] + pub struct SeedParams { + #(#param_fields,)* + } + } + }; + + let has_pda_seeds = pda_seeds.as_ref().map(|p| !p.is_empty()).unwrap_or(false); + let has_token_seeds = token_seeds.as_ref().map(|t| !t.is_empty()).unwrap_or(false); + + let instruction_variant = match (has_pda_seeds, has_token_seeds) { + (true, true) => InstructionVariant::Mixed, + (true, false) => InstructionVariant::PdaOnly, + (false, true) => InstructionVariant::TokenOnly, + (false, false) => { + return Err(macro_error!( + &module, + "At least one PDA or token seed specification must be provided" + )) + } + }; + + let error_codes = generate_error_codes(instruction_variant)?; + + let required_accounts = extract_required_accounts_from_seeds(&pda_seeds, &token_seeds)?; + + let decompress_accounts = + generate_decompress_accounts_struct(&required_accounts, instruction_variant)?; + + let pda_seed_provider_impls: Result> = account_types + .iter() + .map(|name| { + let name_str = name.to_string(); + let spec = if let Some(ref pda_seed_specs) = pda_seeds { + pda_seed_specs + .iter() + .find(|s| s.variant == name_str) + .ok_or_else(|| { + macro_error!( + name, + "No seed specification for account type '{}'. All accounts must have seed specifications.", + name_str + ) + })? + } else { + return Err(macro_error!( + name, + "No seed specifications provided. Use: AccountType = (\"seed\", data.field)" + )); + }; + let seed_derivation = + generate_pda_seed_derivation_for_trait(spec, &instruction_data)?; + Ok(quote! { + impl<'info> light_sdk::compressible::PdaSeedDerivation, SeedParams> for #name { + fn derive_pda_seeds_with_accounts( + &self, + program_id: &solana_pubkey::Pubkey, + accounts: &DecompressAccountsIdempotent<'info>, + seed_params: &SeedParams, + ) -> (Vec>, solana_pubkey::Pubkey) { + #seed_derivation + } + } + }) + }) + .collect(); + let pda_seed_provider_impls = pda_seed_provider_impls?; + + let helper_packed_fns: Vec<_> = account_types.iter().map(|name| { + let packed_name = format_ident!("Packed{}", name); + let func_name = format_ident!("handle_packed_{}", name); + quote! { + #[inline(never)] + #[allow(clippy::too_many_arguments)] + fn #func_name<'a, 'b, 'info>( + accounts: &DecompressAccountsIdempotent<'info>, + cpi_accounts: &light_sdk::cpi::v2::CpiAccounts<'b, 'info>, + address_space: solana_pubkey::Pubkey, + solana_accounts: &[solana_account_info::AccountInfo<'info>], + i: usize, + packed: &#packed_name, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + post_system_accounts: &[solana_account_info::AccountInfo<'info>], + compressed_pda_infos: &mut Vec, + seed_accounts: &DecompressAccountsIdempotent<'info>, + seed_params: &SeedParams, + ) -> std::result::Result<(), solana_program_error::ProgramError> { + light_sdk::compressible::handle_packed_pda_variant::<#name, #packed_name, DecompressAccountsIdempotent<'info>, SeedParams>( + accounts.rent_sponsor.as_ref(), + cpi_accounts, + address_space, + &solana_accounts[i], + i, + packed, + meta, + post_system_accounts, + compressed_pda_infos, + &crate::ID, + seed_accounts, + std::option::Option::Some(seed_params), + ) + } + } + }).collect(); + + let call_unpacked_arms: Vec<_> = account_types.iter().map(|name| { + quote! { + CompressedAccountVariant::#name(_) => { + unreachable!("Unpacked variants should not be present during decompression - accounts are always packed in-flight"); + } + } + }).collect(); + let call_packed_arms: Vec<_> = account_types.iter().map(|name| { + let packed_name = format_ident!("Packed{}", name); + let func_name = format_ident!("handle_packed_{}", name); + quote! { + CompressedAccountVariant::#packed_name(packed) => { + match #func_name(accounts, &cpi_accounts, address_space, solana_accounts, i, &packed, &meta, post_system_accounts, &mut compressed_pda_infos, accounts, seed_params) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(e) => return std::result::Result::Err(e), + } + } + } + }).collect(); + + let trait_impls: syn::ItemMod = syn::parse_quote! { + mod __trait_impls { + use super::*; + + impl light_sdk::compressible::HasTokenVariant for CompressedAccountData { + fn is_packed_ctoken(&self) -> bool { + matches!(self.data, CompressedAccountVariant::PackedCTokenData(_)) + } + } + + impl light_sdk::compressible::CTokenSeedProvider for CTokenAccountVariant { + type Accounts<'info> = DecompressAccountsIdempotent<'info>; + + fn get_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [solana_account_info::AccountInfo<'info>], + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), anchor_lang::prelude::ProgramError> { + use super::ctoken_seed_system::{ + CTokenSeedContext, + CTokenSeedProvider as LocalProvider, + }; + let ctx = CTokenSeedContext { + accounts, + remaining_accounts, + }; + LocalProvider::get_seeds(self, &ctx).map_err(|e: anchor_lang::error::Error| -> anchor_lang::prelude::ProgramError { e.into() }) + } + + fn get_authority_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [solana_account_info::AccountInfo<'info>], + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), anchor_lang::prelude::ProgramError> { + use super::ctoken_seed_system::{ + CTokenSeedContext, + CTokenSeedProvider as LocalProvider, + }; + let ctx = CTokenSeedContext { + accounts, + remaining_accounts, + }; + LocalProvider::get_authority_seeds(self, &ctx).map_err(|e: anchor_lang::error::Error| -> anchor_lang::prelude::ProgramError { e.into() }) + } + } + + impl light_compressed_token_sdk::CTokenSeedProvider for CTokenAccountVariant { + type Accounts<'info> = DecompressAccountsIdempotent<'info>; + + fn get_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [solana_account_info::AccountInfo<'info>], + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { + use super::ctoken_seed_system::{ + CTokenSeedContext, + CTokenSeedProvider as LocalProvider, + }; + let ctx = CTokenSeedContext { + accounts, + remaining_accounts, + }; + LocalProvider::get_seeds(self, &ctx) + .map_err(|e: anchor_lang::error::Error| { + let program_error: anchor_lang::prelude::ProgramError = e.into(); + let code = match program_error { + anchor_lang::prelude::ProgramError::Custom(code) => code, + _ => 0, + }; + solana_program_error::ProgramError::Custom(code) + }) + } + + fn get_authority_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [solana_account_info::AccountInfo<'info>], + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { + use super::ctoken_seed_system::{ + CTokenSeedContext, + CTokenSeedProvider as LocalProvider, + }; + let ctx = CTokenSeedContext { + accounts, + remaining_accounts, + }; + LocalProvider::get_authority_seeds(self, &ctx) + .map_err(|e: anchor_lang::error::Error| { + let program_error: anchor_lang::prelude::ProgramError = e.into(); + let code = match program_error { + anchor_lang::prelude::ProgramError::Custom(code) => code, + _ => 0, + }; + solana_program_error::ProgramError::Custom(code) + }) + } + } + } + }; + + let ctoken_trait_system: syn::ItemMod = syn::parse_quote! { + pub mod ctoken_seed_system { + use super::*; + + pub struct CTokenSeedContext<'a, 'info> { + pub accounts: &'a DecompressAccountsIdempotent<'info>, + pub remaining_accounts: &'a [solana_account_info::AccountInfo<'info>], + } + + pub trait CTokenSeedProvider { + fn get_seeds<'a, 'info>( + &self, + ctx: &CTokenSeedContext<'a, 'info>, + ) -> Result<(Vec>, solana_pubkey::Pubkey)>; + + fn get_authority_seeds<'a, 'info>( + &self, + ctx: &CTokenSeedContext<'a, 'info>, + ) -> Result<(Vec>, solana_pubkey::Pubkey)>; + } + } + }; + + let helpers_module: syn::ItemMod = { + let helper_packed_fns = helper_packed_fns.clone(); + let call_unpacked_arms = call_unpacked_arms.clone(); + let call_packed_arms = call_packed_arms.clone(); + syn::parse_quote! { + mod __macro_helpers { + use super::*; + use crate::state::*; // Import Packed* types from state module + #(#helper_packed_fns)* + #[inline(never)] + pub fn collect_pda_and_token<'a, 'b, 'info>( + accounts: &DecompressAccountsIdempotent<'info>, + cpi_accounts: &light_sdk::cpi::v2::CpiAccounts<'b, 'info>, + address_space: solana_pubkey::Pubkey, + compressed_accounts: Vec, + solana_accounts: &[solana_account_info::AccountInfo<'info>], + seed_params: &SeedParams, + ) -> std::result::Result<( + Vec, + Vec<( + light_compressed_token_sdk::compat::PackedCTokenData, + light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + )>, + ), solana_program_error::ProgramError> { + let post_system_offset = cpi_accounts.system_accounts_end_offset(); + let all_infos = cpi_accounts.account_infos(); + let post_system_accounts = &all_infos[post_system_offset..]; + let estimated_capacity = compressed_accounts.len(); + let mut compressed_pda_infos = Vec::with_capacity(estimated_capacity); + let mut compressed_token_accounts: Vec<( + light_compressed_token_sdk::compat::PackedCTokenData, + light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + )> = Vec::with_capacity(estimated_capacity); + + for (i, compressed_data) in compressed_accounts.into_iter().enumerate() { + let meta = compressed_data.meta; + match compressed_data.data { + #(#call_unpacked_arms)* + #(#call_packed_arms)* + CompressedAccountVariant::PackedCTokenData(mut data) => { + data.token_data.version = 3; + compressed_token_accounts.push((data, meta)); + } + CompressedAccountVariant::CTokenData(_) => { + unreachable!(); + } + } + } + + std::result::Result::Ok((compressed_pda_infos, compressed_token_accounts)) + } + } + } + }; + + let token_variant_name = format_ident!("CTokenAccountVariant"); + + let decompress_context_impl = generate_decompress_context_impl( + instruction_variant, + account_types.clone(), + token_variant_name, + )?; + let decompress_processor_fn = + generate_process_decompress_accounts_idempotent(instruction_variant, &instruction_data)?; + let decompress_instruction = + generate_decompress_instruction_entrypoint(instruction_variant, &instruction_data)?; + + let compress_accounts: syn::ItemStruct = match instruction_variant { + InstructionVariant::PdaOnly => unreachable!(), + InstructionVariant::TokenOnly => unreachable!(), + InstructionVariant::Mixed => syn::parse_quote! { + #[derive(Accounts)] + pub struct CompressAccountsIdempotent<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + /// CHECK: Checked by SDK + pub config: AccountInfo<'info>, + /// CHECK: Checked by SDK + #[account(mut)] + pub rent_sponsor: AccountInfo<'info>, + /// CHECK: Checked by SDK + #[account(mut)] + pub compression_authority: AccountInfo<'info>, + } + }, + }; + + let compress_context_impl = + generate_compress_context_impl(instruction_variant, account_types.clone())?; + let compress_processor_fn = generate_process_compress_accounts_idempotent(instruction_variant)?; + let compress_instruction = generate_compress_instruction_entrypoint(instruction_variant)?; + + let processor_module: syn::ItemMod = syn::parse_quote! { + mod __processor_functions { + use super::*; + #decompress_processor_fn + #compress_processor_fn + } + }; + + let init_config_accounts: syn::ItemStruct = syn::parse_quote! { + #[derive(Accounts)] + pub struct InitializeCompressionConfig<'info> { + #[account(mut)] + pub payer: Signer<'info>, + /// CHECK: Checked by SDK + #[account(mut)] + pub config: AccountInfo<'info>, + /// CHECK: Checked by SDK + pub program_data: AccountInfo<'info>, + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, + } + }; + + let update_config_accounts: syn::ItemStruct = syn::parse_quote! { + #[derive(Accounts)] + pub struct UpdateCompressionConfig<'info> { + /// CHECK: Checked by SDK + #[account(mut)] + pub config: AccountInfo<'info>, + /// CHECK: Checked by SDK + pub authority: Signer<'info>, + } + }; + + let init_config_instruction: syn::ItemFn = syn::parse_quote! { + #[inline(never)] + #[allow(clippy::too_many_arguments)] + pub fn initialize_compression_config<'info>( + ctx: Context<'_, '_, '_, 'info, InitializeCompressionConfig<'info>>, + write_top_up: u32, + rent_sponsor: Pubkey, + compression_authority: Pubkey, + rent_config: light_compressible::rent::RentConfig, + address_space: Vec, + ) -> Result<()> { + light_sdk::compressible::process_initialize_compression_config_checked( + &ctx.accounts.config.to_account_info(), + &ctx.accounts.authority.to_account_info(), + &ctx.accounts.program_data.to_account_info(), + &rent_sponsor, + &compression_authority, + rent_config, + write_top_up, + address_space, + 0, + &ctx.accounts.payer.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + &crate::ID, + )?; + Ok(()) + } + }; + + let update_config_instruction: syn::ItemFn = syn::parse_quote! { + #[inline(never)] + #[allow(clippy::too_many_arguments)] + pub fn update_compression_config<'info>( + ctx: Context<'_, '_, '_, 'info, UpdateCompressionConfig<'info>>, + new_rent_sponsor: Option, + new_compression_authority: Option, + new_rent_config: Option, + new_write_top_up: Option, + new_address_space: Option>, + new_update_authority: Option, + ) -> Result<()> { + light_sdk::compressible::process_update_compression_config( + ctx.accounts.config.as_ref(), + ctx.accounts.authority.as_ref(), + new_update_authority.as_ref(), + new_rent_sponsor.as_ref(), + new_compression_authority.as_ref(), + new_rent_config, + new_write_top_up, + new_address_space, + &crate::ID, + )?; + Ok(()) + } + }; + + // Insert SeedParams struct + let seed_params_item: Item = syn::parse2(seed_params_struct)?; + content.1.push(seed_params_item); + + content.1.push(Item::Struct(decompress_accounts)); + content.1.push(Item::Mod(helpers_module)); + content.1.push(Item::Mod(ctoken_trait_system)); + content.1.push(Item::Mod(trait_impls)); + content.1.push(Item::Mod(decompress_context_impl)); + content.1.push(Item::Mod(processor_module)); + content.1.push(Item::Fn(decompress_instruction)); + content.1.push(Item::Struct(compress_accounts)); + content.1.push(Item::Mod(compress_context_impl)); + content.1.push(Item::Fn(compress_instruction)); + content.1.push(Item::Struct(init_config_accounts)); + content.1.push(Item::Struct(update_config_accounts)); + content.1.push(Item::Fn(init_config_instruction)); + content.1.push(Item::Fn(update_config_instruction)); + + if let Some(ref seeds) = token_seeds { + if !seeds.is_empty() { + let impl_code = + crate::compressible::seed_providers::generate_ctoken_seed_provider_implementation( + seeds, + )?; + let ctoken_impl: syn::ItemImpl = syn::parse2(impl_code).map_err(|e| { + syn::Error::new_spanned( + &seeds[0].variant, + format!("Failed to parse ctoken implementation: {}", e), + ) + })?; + content.1.push(Item::Impl(ctoken_impl)); + } + } + + let client_seed_functions = + crate::compressible::seed_providers::generate_client_seed_functions( + &account_types, + &pda_seeds, + &token_seeds, + &instruction_data, + )?; + + // Add allow attribute to module itself to suppress clippy warnings + module.attrs.push(syn::parse_quote! { + #[allow(clippy::too_many_arguments)] + }); + + Ok(quote! { + #size_validation_checks + #error_codes + #ctoken_enum + #enum_and_traits + #(#pda_seed_provider_impls)* + #[allow(non_snake_case)] + #module + #client_seed_functions + }) +} + +pub fn generate_decompress_context_impl( + _variant: InstructionVariant, + pda_type_idents: Vec, + token_variant_ident: Ident, +) -> Result { + let lifetime: syn::Lifetime = syn::parse_quote!('info); + + let trait_impl = + crate::compressible::decompress_context::generate_decompress_context_trait_impl( + pda_type_idents, + token_variant_ident, + lifetime, + )?; + + Ok(syn::parse_quote! { + mod __decompress_context_impl { + use super::*; + + #trait_impl + } + }) +} + +pub fn generate_process_decompress_accounts_idempotent( + _variant: InstructionVariant, + instruction_data: &[InstructionDataSpec], +) -> Result { + // If we have seed parameters, accept them as a single struct + let (params, seed_params_arg) = if !instruction_data.is_empty() { + ( + quote! { seed_data: SeedParams, }, + quote! { std::option::Option::Some(&seed_data) }, + ) + } else { + (quote! {}, quote! { std::option::Option::None }) + }; + + Ok(syn::parse_quote! { + #[inline(never)] + pub fn process_decompress_accounts_idempotent<'info>( + accounts: &DecompressAccountsIdempotent<'info>, + remaining_accounts: &[solana_account_info::AccountInfo<'info>], + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + #params + ) -> Result<()> { + light_sdk::compressible::process_decompress_accounts_idempotent( + accounts, + remaining_accounts, + compressed_accounts, + proof, + system_accounts_offset, + LIGHT_CPI_SIGNER, + &crate::ID, + #seed_params_arg, + ) + .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) + } + }) +} + +pub fn generate_decompress_instruction_entrypoint( + _variant: InstructionVariant, + instruction_data: &[InstructionDataSpec], +) -> Result { + // If we have seed parameters, pass them as a single struct + let (params, args) = if !instruction_data.is_empty() { + (quote! { seed_data: SeedParams, }, quote! { seed_data, }) + } else { + (quote! {}, quote! {}) + }; + + Ok(syn::parse_quote! { + #[inline(never)] + pub fn decompress_accounts_idempotent<'info>( + ctx: Context<'_, '_, '_, 'info, DecompressAccountsIdempotent<'info>>, + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + #params + ) -> Result<()> { + __processor_functions::process_decompress_accounts_idempotent( + &ctx.accounts, + &ctx.remaining_accounts, + proof, + compressed_accounts, + system_accounts_offset, + #args + ) + } + }) +} + +pub fn generate_compress_context_impl( + _variant: InstructionVariant, + account_types: Vec, +) -> Result { + let lifetime: syn::Lifetime = syn::parse_quote!('info); + + let compress_arms: Vec<_> = account_types.iter().map(|name| { + quote! { + d if d == #name::LIGHT_DISCRIMINATOR => { + drop(data); + let data_borrow = account_info.try_borrow_data().map_err(|e| { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + let code = match program_error { + anchor_lang::prelude::ProgramError::Custom(code) => code, + _ => 0, + }; + solana_program_error::ProgramError::Custom(code) + })?; + let mut account_data = #name::try_deserialize(&mut &data_borrow[..]).map_err(|e| { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + let code = match program_error { + anchor_lang::prelude::ProgramError::Custom(code) => code, + _ => 0, + }; + solana_program_error::ProgramError::Custom(code) + })?; + drop(data_borrow); + + let compressed_info = light_sdk::compressible::compress_account::prepare_account_for_compression::<#name>( + program_id, + account_info, + &mut account_data, + meta, + cpi_accounts, + &compression_config.address_space, + )?; + // Compute rent-based close distribution and transfer lamports: + // - Completed epochs to rent sponsor + // - Partial epoch (unused) to fee payer (user refund) + #[cfg(target_os = "solana")] + let current_slot = anchor_lang::solana_program::sysvar::clock::Clock::get() + .map_err(|_| anchor_lang::prelude::ProgramError::UnsupportedSysvar)? + .slot; + #[cfg(not(target_os = "solana"))] + let current_slot = 0; + let bytes = account_info.data_len() as u64; + let current_lamports = account_info.lamports(); + let rent_exemption = anchor_lang::solana_program::sysvar::rent::Rent::get() + .map_err(|_| anchor_lang::prelude::ProgramError::UnsupportedSysvar)? + .minimum_balance(bytes as usize); + let ci_ref = account_data.compression_info(); + let state = light_compressible::rent::AccountRentState { + num_bytes: bytes, + current_slot, + current_lamports, + last_claimed_slot: ci_ref.last_claimed_slot(), + }; + let dist = state.calculate_close_distribution(&ci_ref.rent_config, rent_exemption); + // Transfer partial epoch back to fee payer (user) + if dist.to_user > 0 { + let fee_payer_info = self.fee_payer.to_account_info(); + let mut src = account_info.try_borrow_mut_lamports().map_err(|e| { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + program_error + })?; + let mut dst = fee_payer_info.try_borrow_mut_lamports().map_err(|e| { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + program_error + })?; + **src = src.checked_sub(dist.to_user).ok_or(anchor_lang::prelude::ProgramError::InsufficientFunds)?; + **dst = dst.checked_add(dist.to_user).ok_or(anchor_lang::prelude::ProgramError::Custom(0))?; + } + // Transfer completed epochs (and base) to rent sponsor + if dist.to_rent_sponsor > 0 { + let rent_sponsor_info = self.rent_sponsor.to_account_info(); + let mut src = account_info.try_borrow_mut_lamports().map_err(|e| { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + program_error + })?; + let mut dst = rent_sponsor_info.try_borrow_mut_lamports().map_err(|e| { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + program_error + })?; + **src = src.checked_sub(dist.to_rent_sponsor).ok_or(anchor_lang::prelude::ProgramError::InsufficientFunds)?; + **dst = dst.checked_add(dist.to_rent_sponsor).ok_or(anchor_lang::prelude::ProgramError::Custom(0))?; + } + Ok(Some(compressed_info)) + } + } + }).collect(); + + Ok(syn::parse_quote! { + mod __compress_context_impl { + use super::*; + use light_sdk::LightDiscriminator; + use light_sdk::compressible::HasCompressionInfo; + + impl<#lifetime> light_sdk::compressible::CompressContext<#lifetime> for CompressAccountsIdempotent<#lifetime> { + fn fee_payer(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &*self.fee_payer + } + + fn config(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.config + } + + fn rent_sponsor(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.rent_sponsor + } + + fn compression_authority(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.compression_authority + } + + fn compress_pda_account( + &self, + account_info: &solana_account_info::AccountInfo<#lifetime>, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + cpi_accounts: &light_sdk::cpi::v2::CpiAccounts<'_, #lifetime>, + compression_config: &light_sdk::compressible::CompressibleConfig, + program_id: &solana_pubkey::Pubkey, + ) -> std::result::Result, solana_program_error::ProgramError> { + let data = account_info.try_borrow_data().map_err(|e| { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + let code = match program_error { + anchor_lang::prelude::ProgramError::Custom(code) => code, + _ => 0, + }; + solana_program_error::ProgramError::Custom(code) + })?; + let discriminator = &data[0..8]; + + match discriminator { + #(#compress_arms)* + _ => { + let err: anchor_lang::error::Error = anchor_lang::error::ErrorCode::AccountDiscriminatorMismatch.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + let code = match program_error { + anchor_lang::prelude::ProgramError::Custom(code) => code, + _ => 0, + }; + Err(solana_program_error::ProgramError::Custom(code)) + } + } + } + } + } + }) +} + +pub fn generate_process_compress_accounts_idempotent( + _variant: InstructionVariant, +) -> Result { + Ok(syn::parse_quote! { + #[inline(never)] + pub fn process_compress_accounts_idempotent<'info>( + accounts: &CompressAccountsIdempotent<'info>, + remaining_accounts: &[solana_account_info::AccountInfo<'info>], + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + light_sdk::compressible::compress_runtime::process_compress_pda_accounts_idempotent( + accounts, + remaining_accounts, + compressed_accounts, + system_accounts_offset, + LIGHT_CPI_SIGNER, + &crate::ID, + ) + .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) + } + }) +} + +pub fn generate_compress_instruction_entrypoint( + _variant: InstructionVariant, +) -> Result { + Ok(syn::parse_quote! { + #[inline(never)] + #[allow(clippy::too_many_arguments)] + pub fn compress_accounts_idempotent<'info>( + ctx: Context<'_, '_, '_, 'info, CompressAccountsIdempotent<'info>>, + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + __processor_functions::process_compress_accounts_idempotent( + &ctx.accounts, + &ctx.remaining_accounts, + compressed_accounts, + system_accounts_offset, + ) + } + }) +} + +#[inline(never)] +fn generate_pda_seed_derivation_for_trait( + spec: &TokenSeedSpec, + _instruction_data: &[InstructionDataSpec], +) -> Result { + let mut bindings: Vec = Vec::new(); + let mut seed_refs = Vec::new(); + + // Recursively rewrite expressions: + // - `data.` -> `seed_params.` (from instruction params, not struct fields!) + // - `ctx.accounts.` -> `accounts.` + // - `ctx.` -> `accounts.` + // While preserving function/method calls and references. + fn map_pda_expr_to_params(expr: &syn::Expr) -> syn::Expr { + match expr { + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + // Handle nested field access: ctx.accounts.field_name -> accounts.field_name.as_ref().unwrap().key() + if let syn::Expr::Field(nested_field) = &*field_expr.base { + if let syn::Member::Named(base_name) = &nested_field.member { + if base_name == "accounts" { + if let syn::Expr::Path(path) = &*nested_field.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "ctx" { + return syn::parse_quote! { *accounts.#field_name.as_ref().unwrap().key }; + } + } + } + } + } + } + // Handle direct field access + if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "data" { + // data.field -> seed_params.field (from instruction params!) + return syn::parse_quote! { seed_params.#field_name }; + } else if segment.ident == "ctx" { + // ctx.field -> accounts.field.as_ref().unwrap().key() (unwrap if optional) + return syn::parse_quote! { *accounts.#field_name.as_ref().unwrap().key }; + } + } + } + } + expr.clone() + } + syn::Expr::MethodCall(method_call) => { + // Special case: ctx.accounts.account_name.key() -> accounts.account_name.key() + // This is already handled by the Field case transforming ctx.accounts.X -> accounts.X + let mut new_method_call = method_call.clone(); + new_method_call.receiver = Box::new(map_pda_expr_to_params(&method_call.receiver)); + new_method_call.args = method_call + .args + .iter() + .map(map_pda_expr_to_params) + .collect(); + syn::Expr::MethodCall(new_method_call) + } + syn::Expr::Call(call_expr) => { + // Map function args recursively. We do not transform the function path. + let mut new_call_expr = call_expr.clone(); + new_call_expr.args = call_expr.args.iter().map(map_pda_expr_to_params).collect(); + syn::Expr::Call(new_call_expr) + } + syn::Expr::Reference(ref_expr) => { + let mut new_ref_expr = ref_expr.clone(); + new_ref_expr.expr = Box::new(map_pda_expr_to_params(&ref_expr.expr)); + syn::Expr::Reference(new_ref_expr) + } + _ => { + // For other expressions (constants, literals, paths), leave as-is + expr.clone() + } + } + } + + for (i, seed) in spec.seeds.iter().enumerate() { + match seed { + SeedElement::Literal(lit) => { + let value = lit.value(); + seed_refs.push(quote! { #value.as_bytes() }); + } + SeedElement::Expression(expr) => { + if let syn::Expr::Path(path_expr) = &**expr { + if let Some(ident) = path_expr.path.get_ident() { + let ident_str = ident.to_string(); + if ident_str.chars().all(|c| c.is_uppercase() || c == '_') { + seed_refs.push(quote! { #ident.as_bytes() }); + continue; + } + } + } + + // Generic solution: rewrite any `data.*` occurrences recursively to `self.*`, + // then bind the result to a local to ensure lifetimes are valid, + // and use `.as_ref()` to convert into a seed `&[u8]`. + let binding_name = + syn::Ident::new(&format!("seed_{}", i), proc_macro2::Span::call_site()); + let mapped_expr = map_pda_expr_to_params(expr); + bindings.push(quote! { + let #binding_name = #mapped_expr; + }); + seed_refs.push(quote! { (#binding_name).as_ref() }); + } + } + } + + let indices: Vec = (0..seed_refs.len()).collect(); + + Ok(quote! { + #(#bindings)* + let seeds: &[&[u8]] = &[#(#seed_refs,)*]; + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, program_id); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + #( + seeds_vec.push(seeds[#indices].to_vec()); + )* + seeds_vec.push(vec![bump]); + (seeds_vec, pda) + }) +} + +#[inline(never)] +fn extract_required_accounts_from_seeds( + pda_seeds: &Option>, + token_seeds: &Option>, +) -> Result> { + let mut required_accounts: Vec = Vec::new(); + + #[inline(always)] + fn push_unique(list: &mut Vec, value: String) { + if !list.iter().any(|v| v == &value) { + list.push(value); + } + } + + #[inline(never)] + fn extract_accounts_from_seed_spec( + spec: &TokenSeedSpec, + ordered_accounts: &mut Vec, + ) -> Result> { + let mut spec_accounts = Vec::new(); + for seed in &spec.seeds { + if let SeedElement::Expression(expr) = seed { + let mut local_accounts = Vec::new(); + extract_account_from_expr(expr, &mut local_accounts); + for acc in local_accounts { + push_unique(ordered_accounts, acc.clone()); + push_unique(&mut spec_accounts, acc); + } + } + } + if let Some(authority_seeds) = &spec.authority { + for seed in authority_seeds { + if let SeedElement::Expression(expr) = seed { + let mut local_accounts = Vec::new(); + extract_account_from_expr(expr, &mut local_accounts); + for acc in local_accounts { + push_unique(ordered_accounts, acc.clone()); + push_unique(&mut spec_accounts, acc); + } + } + } + } + Ok(spec_accounts) + } + + if let Some(pda_seed_specs) = pda_seeds { + for spec in pda_seed_specs { + let _required_seeds = extract_accounts_from_seed_spec(spec, &mut required_accounts)?; + } + } + + if let Some(token_seed_specs) = token_seeds { + for spec in token_seed_specs { + let _required_seeds = extract_accounts_from_seed_spec(spec, &mut required_accounts)?; + } + } + + Ok(required_accounts) +} + +#[inline(never)] +fn extract_account_from_expr(expr: &syn::Expr, ordered_accounts: &mut Vec) { + #[inline(always)] + fn push_unique(list: &mut Vec, value: String) { + if !list.iter().any(|v| v == &value) { + list.push(value); + } + } + + match expr { + syn::Expr::MethodCall(method_call) => { + extract_account_from_expr(&method_call.receiver, ordered_accounts); + } + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + if let syn::Expr::Field(nested_field) = &*field_expr.base { + if let syn::Member::Named(base_name) = &nested_field.member { + if base_name == "accounts" { + if let syn::Expr::Path(path) = &*nested_field.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "ctx" { + push_unique(ordered_accounts, field_name.to_string()); + } + } + } + } + } + } else if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "ctx" && field_name != "accounts" { + push_unique(ordered_accounts, field_name.to_string()); + } + } + } + } + } + syn::Expr::Path(path_expr) => { + if let Some(ident) = path_expr.path.get_ident() { + let name = ident.to_string(); + if name != "ctx" + && name != "data" + && !name + .chars() + .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) + { + push_unique(ordered_accounts, name); + } + } + } + syn::Expr::Call(call_expr) => { + for arg in &call_expr.args { + extract_account_from_expr(arg, ordered_accounts); + } + } + syn::Expr::Reference(ref_expr) => { + extract_account_from_expr(&ref_expr.expr, ordered_accounts); + } + _ => {} + } +} + +#[inline(never)] +fn generate_decompress_accounts_struct( + required_accounts: &[String], + variant: InstructionVariant, +) -> Result { + let mut account_fields = vec![ + quote! { + #[account(mut)] + pub fee_payer: Signer<'info> + }, + quote! { + /// CHECK: Checked by SDK + pub config: AccountInfo<'info> + }, + ]; + + match variant { + InstructionVariant::PdaOnly => { + unreachable!() + } + InstructionVariant::TokenOnly => { + unreachable!() + } + InstructionVariant::Mixed => { + account_fields.extend(vec![ + quote! { + /// CHECK: anyone can pay + #[account(mut)] + pub rent_sponsor: UncheckedAccount<'info> + }, + quote! { + /// CHECK: optional - only needed if decompressing tokens + #[account(mut)] + pub ctoken_rent_sponsor: Option> + }, + ]); + } + } + + match variant { + InstructionVariant::TokenOnly => { + unreachable!() + } + InstructionVariant::Mixed => { + account_fields.extend(vec![ + quote! { + /// CHECK: + #[account(address = solana_pubkey::pubkey!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"))] + pub ctoken_program: Option> + }, + quote! { + /// CHECK: + #[account(address = solana_pubkey::pubkey!("GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy"))] + pub ctoken_cpi_authority: Option> + }, + quote! { + /// CHECK: Checked by SDK + pub ctoken_config: Option> + }, + ]); + } + InstructionVariant::PdaOnly => { + unreachable!() + } + } + + let standard_fields = [ + "fee_payer", + "rent_sponsor", + "ctoken_rent_sponsor", + "config", + "ctoken_program", + "ctoken_cpi_authority", + "ctoken_config", + ]; + + for account_name in required_accounts { + if !standard_fields.contains(&account_name.as_str()) { + let account_ident = syn::Ident::new(account_name, proc_macro2::Span::call_site()); + account_fields.push(quote! { + /// CHECK: optional seed account + pub #account_ident: Option> + }); + } + } + + let struct_def = quote! { + #[derive(Accounts)] + pub struct DecompressAccountsIdempotent<'info> { + #(#account_fields,)* + } + }; + + syn::parse2(struct_def) +} + +#[inline(never)] +fn validate_compressed_account_sizes(account_types: &[Ident]) -> Result { + let size_checks: Vec<_> = account_types.iter().map(|account_type| { + quote! { + const _: () = { + const COMPRESSED_SIZE: usize = 8 + <#account_type as light_sdk::compressible::compression_info::CompressedInitSpace>::COMPRESSED_INIT_SPACE; + if COMPRESSED_SIZE > 800 { + panic!(concat!( + "Compressed account '", stringify!(#account_type), "' exceeds 800-byte compressible account size limit. If you need support for larger accounts, send a message to team@lightprotocol.com" + )); + } + }; + } + }).collect(); + + Ok(quote! { #(#size_checks)* }) +} + +#[inline(never)] +fn generate_error_codes(variant: InstructionVariant) -> Result { + let base_errors = quote! { + #[msg("Rent sponsor mismatch")] + InvalidRentSponsor, + #[msg("Missing seed account")] + MissingSeedAccount, + #[msg("ATA uses SPL ATA derivation")] + AtaDoesNotUseSeedDerivation, + }; + + let variant_specific_errors = match variant { + InstructionVariant::PdaOnly => unreachable!(), + InstructionVariant::TokenOnly => unreachable!(), + InstructionVariant::Mixed => quote! { + #[msg("Not implemented")] + CTokenDecompressionNotImplemented, + #[msg("Not implemented")] + PdaDecompressionNotImplemented, + #[msg("Not implemented")] + TokenCompressionNotImplemented, + #[msg("Not implemented")] + PdaCompressionNotImplemented, + }, + }; + + Ok(quote! { + #[error_code] + pub enum CompressibleInstructionError { + #base_errors + #variant_specific_errors + } + }) +} diff --git a/sdk-libs/macros/src/compressible/mod.rs b/sdk-libs/macros/src/compressible/mod.rs new file mode 100644 index 0000000000..fb11aaa1b2 --- /dev/null +++ b/sdk-libs/macros/src/compressible/mod.rs @@ -0,0 +1,9 @@ +//! Compressible account macro generation. + +pub mod decompress_context; +pub mod instructions; +pub mod pack_unpack; +pub mod seed_providers; +pub mod traits; +pub mod utils; +pub mod variant_enum; diff --git a/sdk-libs/macros/src/compressible/pack_unpack.rs b/sdk-libs/macros/src/compressible/pack_unpack.rs new file mode 100644 index 0000000000..01ed4b99e4 --- /dev/null +++ b/sdk-libs/macros/src/compressible/pack_unpack.rs @@ -0,0 +1,186 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{DeriveInput, Result}; + +use super::utils::{extract_fields_from_derive_input, is_copy_type, is_pubkey_type}; + +#[inline(never)] +pub fn derive_compressible_pack(input: DeriveInput) -> Result { + let struct_name = &input.ident; + let packed_struct_name = format_ident!("Packed{}", struct_name); + let fields = extract_fields_from_derive_input(&input)?; + + let has_pubkey_fields = fields.iter().any(|field| is_pubkey_type(&field.ty)); + + if has_pubkey_fields { + generate_with_packed_struct(struct_name, &packed_struct_name, fields) + } else { + generate_identity_pack_unpack(struct_name) + } +} + +#[inline(never)] +fn generate_with_packed_struct( + struct_name: &syn::Ident, + packed_struct_name: &syn::Ident, + fields: &syn::punctuated::Punctuated, +) -> Result { + let packed_fields = fields.iter().map(|field| { + let field_name = field.ident.as_ref().unwrap(); + let field_type = &field.ty; + + let packed_type = if is_pubkey_type(field_type) { + quote! { u8 } + } else { + quote! { #field_type } + }; + + quote! { pub #field_name: #packed_type } + }); + + let packed_struct = quote! { + #[derive(Debug, Clone, anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] + pub struct #packed_struct_name { + #(#packed_fields,)* + } + }; + + let pack_field_assignments = fields.iter().map(|field| { + let field_name = field.ident.as_ref().unwrap(); + let field_type = &field.ty; + + if *field_name == "compression_info" { + quote! { #field_name: None } + } else if is_pubkey_type(field_type) { + quote! { #field_name: remaining_accounts.insert_or_get(self.#field_name) } + } else if is_copy_type(field_type) { + quote! { #field_name: self.#field_name } + } else { + quote! { #field_name: self.#field_name.clone() } + } + }); + + let pack_impl = quote! { + impl light_sdk::compressible::Pack for #struct_name { + type Packed = #packed_struct_name; + + #[inline(never)] + fn pack(&self, remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + #packed_struct_name { + #(#pack_field_assignments,)* + } + } + } + }; + + let unpack_impl_original = quote! { + impl light_sdk::compressible::Unpack for #struct_name { + type Unpacked = Self; + + #[inline(never)] + fn unpack( + &self, + _remaining_accounts: &[anchor_lang::prelude::AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } + } + }; + + let pack_impl_packed = quote! { + impl light_sdk::compressible::Pack for #packed_struct_name { + type Packed = Self; + + #[inline(never)] + fn pack(&self, _remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + self.clone() + } + } + }; + + let unpack_field_assignments = fields.iter().map(|field| { + let field_name = field.ident.as_ref().unwrap(); + let field_type = &field.ty; + + if *field_name == "compression_info" { + quote! { #field_name: None } + } else if is_pubkey_type(field_type) { + quote! { + #field_name: *remaining_accounts[self.#field_name as usize].key + } + } else if is_copy_type(field_type) { + quote! { #field_name: self.#field_name } + } else { + quote! { #field_name: self.#field_name.clone() } + } + }); + + let unpack_impl_packed = quote! { + impl light_sdk::compressible::Unpack for #packed_struct_name { + type Unpacked = #struct_name; + + #[inline(never)] + fn unpack( + &self, + remaining_accounts: &[anchor_lang::prelude::AccountInfo], + ) -> std::result::Result { + Ok(#struct_name { + #(#unpack_field_assignments,)* + }) + } + } + }; + + let expanded = quote! { + #packed_struct + #pack_impl + #unpack_impl_original + #pack_impl_packed + #unpack_impl_packed + }; + + Ok(expanded) +} + +#[inline(never)] +fn generate_identity_pack_unpack(struct_name: &syn::Ident) -> Result { + let packed_struct_name = format_ident!("Packed{}", struct_name); + + // Generate type alias for consistency - Packed{Name} = {Name} + let type_alias = quote! { + pub type #packed_struct_name = #struct_name; + }; + + let pack_impl = quote! { + impl light_sdk::compressible::Pack for #struct_name { + type Packed = #struct_name; + + #[inline(never)] + fn pack(&self, _remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + self.clone() + } + } + }; + + let unpack_impl = quote! { + impl light_sdk::compressible::Unpack for #struct_name { + type Unpacked = Self; + + #[inline(never)] + fn unpack( + &self, + _remaining_accounts: &[anchor_lang::prelude::AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } + } + }; + + let expanded = quote! { + #type_alias + #pack_impl + #unpack_impl + }; + + Ok(expanded) +} diff --git a/sdk-libs/macros/src/compressible/seed_providers.rs b/sdk-libs/macros/src/compressible/seed_providers.rs new file mode 100644 index 0000000000..e8321b0940 --- /dev/null +++ b/sdk-libs/macros/src/compressible/seed_providers.rs @@ -0,0 +1,897 @@ +//! Seed provider generation for PDA and CToken accounts. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{spanned::Spanned, Ident, Result}; + +use crate::compressible::instructions::{InstructionDataSpec, SeedElement, TokenSeedSpec}; + +pub fn generate_ctoken_account_variant_enum(token_seeds: &[TokenSeedSpec]) -> Result { + let variants = token_seeds.iter().enumerate().map(|(index, spec)| { + let variant_name = &spec.variant; + let index_u8 = index as u8; + quote! { + #variant_name = #index_u8, + } + }); + + Ok(quote! { + #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] + #[repr(u8)] + pub enum CTokenAccountVariant { + #(#variants)* + } + }) +} + +pub fn generate_ctoken_seed_provider_implementation( + token_seeds: &[TokenSeedSpec], +) -> Result { + let mut get_seeds_match_arms = Vec::new(); + let mut get_authority_seeds_match_arms = Vec::new(); + + for spec in token_seeds { + let variant_name = &spec.variant; + + if spec.is_ata { + let get_seeds_arm = quote! { + CTokenAccountVariant::#variant_name => { + Err(anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::AtaDoesNotUseSeedDerivation.into() + ).into()) + } + }; + get_seeds_match_arms.push(get_seeds_arm); + + let authority_arm = quote! { + CTokenAccountVariant::#variant_name => { + Err(anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::AtaDoesNotUseSeedDerivation.into() + ).into()) + } + }; + get_authority_seeds_match_arms.push(authority_arm); + continue; + } + + let mut token_bindings = Vec::new(); + let mut token_seed_refs = Vec::new(); + + for (i, seed) in spec.seeds.iter().enumerate() { + match seed { + SeedElement::Literal(lit) => { + let value = lit.value(); + token_seed_refs.push(quote! { #value.as_bytes() }); + } + SeedElement::Expression(expr) => { + if let syn::Expr::Path(path_expr) = &**expr { + if let Some(ident) = path_expr.path.get_ident() { + let ident_str = ident.to_string(); + if ident_str.chars().all(|c| c.is_uppercase() || c == '_') { + if ident_str == "LIGHT_CPI_SIGNER" { + token_seed_refs.push(quote! { #ident.cpi_signer.as_ref() }); + } else { + token_seed_refs.push(quote! { #ident.as_bytes() }); + } + continue; + } + } + } + + let mut handled = false; + if let syn::Expr::Field(field_expr) = &**expr { + if let syn::Member::Named(field_name) = &field_expr.member { + if let syn::Expr::Field(nested_field) = &*field_expr.base { + if let syn::Member::Named(base_name) = &nested_field.member { + if base_name == "accounts" { + if let syn::Expr::Path(path) = &*nested_field.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "ctx" { + let binding_name = syn::Ident::new( + &format!("seed_{}", i), + expr.span(), + ); + let field_name_str = field_name.to_string(); + let is_standard_field = matches!( + field_name_str.as_str(), + "fee_payer" + | "rent_sponsor" + | "config" + | "ctoken_rent_sponsor" + | "ctoken_program" + | "ctoken_cpi_authority" + | "ctoken_config" + | "compression_authority" + | "ctoken_compression_authority" + ); + if is_standard_field { + token_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name.key(); + }); + } else { + token_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name + .as_ref() + .ok_or_else(|| -> anchor_lang::error::Error { + anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::MissingSeedAccount.into() + ).into() + })? + .key(); + }); + } + token_seed_refs + .push(quote! { #binding_name.as_ref() }); + handled = true; + } + } + } + } + } + } else if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "ctx" { + let binding_name = + syn::Ident::new(&format!("seed_{}", i), expr.span()); + let field_name_str = field_name.to_string(); + let is_standard_field = matches!( + field_name_str.as_str(), + "fee_payer" + | "rent_sponsor" + | "config" + | "ctoken_rent_sponsor" + | "ctoken_program" + | "ctoken_cpi_authority" + | "ctoken_config" + | "compression_authority" + | "ctoken_compression_authority" + ); + if is_standard_field { + token_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name.key(); + }); + } else { + token_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name + .as_ref() + .ok_or_else(|| -> anchor_lang::error::Error { + anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::MissingSeedAccount.into() + ).into() + })? + .key(); + }); + } + token_seed_refs.push(quote! { #binding_name.as_ref() }); + handled = true; + } + } + } + } + } + + if !handled { + token_seed_refs.push(quote! { (#expr).as_ref() }); + } + } + } + } + + let get_seeds_arm = quote! { + CTokenAccountVariant::#variant_name => { + #(#token_bindings)* + let seeds: &[&[u8]] = &[#(#token_seed_refs),*]; + let (token_account_pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(vec![bump]); + Ok((seeds_vec, token_account_pda)) + } + }; + get_seeds_match_arms.push(get_seeds_arm); + + if let Some(authority_seeds) = &spec.authority { + let mut auth_bindings: Vec = Vec::new(); + let mut auth_seed_refs = Vec::new(); + + for (i, authority_seed) in authority_seeds.iter().enumerate() { + match authority_seed { + SeedElement::Literal(lit) => { + let value = lit.value(); + auth_seed_refs.push(quote! { #value.as_bytes() }); + } + SeedElement::Expression(expr) => { + let mut handled = false; + match &**expr { + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + if let syn::Expr::Field(nested_field) = &*field_expr.base { + if let syn::Member::Named(base_name) = &nested_field.member + { + if base_name == "accounts" { + if let syn::Expr::Path(path) = &*nested_field.base { + if let Some(segment) = + path.path.segments.first() + { + if segment.ident == "ctx" { + let binding_name = syn::Ident::new( + &format!("authority_seed_{}", i), + expr.span(), + ); + let field_name_str = + field_name.to_string(); + let is_standard_field = matches!( + field_name_str.as_str(), + "fee_payer" | "rent_sponsor" | "config" + | "ctoken_rent_sponsor" | "ctoken_program" + | "ctoken_cpi_authority" | "ctoken_config" + | "compression_authority" | "ctoken_compression_authority" + ); + if is_standard_field { + auth_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name.key(); + }); + } else { + auth_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name + .as_ref() + .ok_or_else(|| -> anchor_lang::error::Error { + anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::MissingSeedAccount.into() + ).into() + })? + .key(); + }); + } + auth_seed_refs.push( + quote! { #binding_name.as_ref() }, + ); + handled = true; + } + } + } + } + } + } else if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "ctx" { + let binding_name = syn::Ident::new( + &format!("authority_seed_{}", i), + expr.span(), + ); + let field_name_str = field_name.to_string(); + let is_standard_field = matches!( + field_name_str.as_str(), + "fee_payer" + | "rent_sponsor" + | "config" + | "ctoken_rent_sponsor" + | "ctoken_program" + | "ctoken_cpi_authority" + | "ctoken_config" + | "compression_authority" + | "ctoken_compression_authority" + ); + if is_standard_field { + auth_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name.key(); + }); + } else { + auth_bindings.push(quote! { + let #binding_name = ctx.accounts.#field_name + .as_ref() + .ok_or_else(|| -> anchor_lang::error::Error { + anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::MissingSeedAccount.into() + ).into() + })? + .key(); + }); + } + auth_seed_refs + .push(quote! { #binding_name.as_ref() }); + handled = true; + } + } + } + } + } + syn::Expr::MethodCall(_mc) => { + auth_seed_refs.push(quote! { (#expr).as_ref() }); + handled = true; + } + syn::Expr::Path(path_expr) => { + if let Some(ident) = path_expr.path.get_ident() { + let ident_str = ident.to_string(); + if ident_str.chars().all(|c| c.is_uppercase() || c == '_') { + if ident_str == "LIGHT_CPI_SIGNER" { + auth_seed_refs + .push(quote! { #ident.cpi_signer.as_ref() }); + } else { + auth_seed_refs.push(quote! { #ident.as_bytes() }); + } + handled = true; + } + } + } + _ => {} + } + + if !handled { + auth_seed_refs.push(quote! { (#expr).as_ref() }); + } + } + } + } + + let authority_arm = quote! { + CTokenAccountVariant::#variant_name => { + #(#auth_bindings)* + let seeds: &[&[u8]] = &[#(#auth_seed_refs),*]; + let (authority_pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(vec![bump]); + Ok((seeds_vec, authority_pda)) + } + }; + get_authority_seeds_match_arms.push(authority_arm); + } else { + let authority_arm = quote! { + CTokenAccountVariant::#variant_name => { + Err(anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::MissingSeedAccount.into() + ).into()) + } + }; + get_authority_seeds_match_arms.push(authority_arm); + } + } + + Ok(quote! { + impl ctoken_seed_system::CTokenSeedProvider for CTokenAccountVariant { + fn get_seeds<'a, 'info>( + &self, + ctx: &ctoken_seed_system::CTokenSeedContext<'a, 'info>, + ) -> Result<(Vec>, solana_pubkey::Pubkey)> { + match self { + #(#get_seeds_match_arms)* + _ => Err(anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::MissingSeedAccount.into() + ).into()) + } + } + + fn get_authority_seeds<'a, 'info>( + &self, + ctx: &ctoken_seed_system::CTokenSeedContext<'a, 'info>, + ) -> Result<(Vec>, solana_pubkey::Pubkey)> { + match self { + #(#get_authority_seeds_match_arms)* + _ => Err(anchor_lang::prelude::ProgramError::Custom( + CompressibleInstructionError::MissingSeedAccount.into() + ).into()) + } + } + } + }) +} + +#[inline(never)] +pub fn generate_client_seed_functions( + _account_types: &[Ident], + pda_seeds: &Option>, + token_seeds: &Option>, + instruction_data: &[InstructionDataSpec], +) -> Result { + let mut functions = Vec::new(); + + if let Some(pda_seed_specs) = pda_seeds { + for spec in pda_seed_specs { + let variant_name = &spec.variant; + let snake_case = camel_to_snake_case(&variant_name.to_string()); + let function_name = format_ident!("get_{}_seeds", snake_case); + + let (parameters, seed_expressions) = + analyze_seed_spec_for_client(spec, instruction_data)?; + + let seed_count = seed_expressions.len(); + let function = quote! { + pub fn #function_name(#(#parameters),*) -> (Vec>, solana_pubkey::Pubkey) { + let mut seed_values = Vec::with_capacity(#seed_count + 1); + #( + seed_values.push((#seed_expressions).to_vec()); + )* + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, &crate::ID); + seed_values.push(vec![bump]); + (seed_values, pda) + } + }; + functions.push(function); + } + } + + if let Some(token_seed_specs) = token_seeds { + for spec in token_seed_specs { + let variant_name = &spec.variant; + + if spec.is_ata { + continue; + } + + let function_name = + format_ident!("get_{}_seeds", variant_name.to_string().to_lowercase()); + + let (parameters, seed_expressions) = + analyze_seed_spec_for_client(spec, instruction_data)?; + + let seed_count = seed_expressions.len(); + let function = quote! { + pub fn #function_name(#(#parameters),*) -> (Vec>, solana_pubkey::Pubkey) { + let mut seed_values = Vec::with_capacity(#seed_count + 1); + #( + seed_values.push((#seed_expressions).to_vec()); + )* + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, &crate::ID); + seed_values.push(vec![bump]); + (seed_values, pda) + } + }; + functions.push(function); + + if let Some(authority_seeds) = &spec.authority { + let authority_function_name = format_ident!( + "get_{}_authority_seeds", + variant_name.to_string().to_lowercase() + ); + + let mut authority_spec = TokenSeedSpec { + variant: spec.variant.clone(), + _eq: spec._eq, + is_token: spec.is_token, + is_ata: spec.is_ata, + seeds: syn::punctuated::Punctuated::new(), + authority: None, + }; + + for auth_seed in authority_seeds { + authority_spec.seeds.push(auth_seed.clone()); + } + + let (auth_parameters, auth_seed_expressions) = + analyze_seed_spec_for_client(&authority_spec, instruction_data)?; + + let auth_seed_count = auth_seed_expressions.len(); + let (fn_params, fn_body) = if auth_parameters.is_empty() { + ( + quote! { _program_id: &solana_pubkey::Pubkey }, + quote! { + let mut seed_values = Vec::with_capacity(#auth_seed_count + 1); + #( + seed_values.push((#auth_seed_expressions).to_vec()); + )* + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, _program_id); + seed_values.push(vec![bump]); + (seed_values, pda) + }, + ) + } else { + ( + quote! { #(#auth_parameters),* }, + quote! { + let mut seed_values = Vec::with_capacity(#auth_seed_count + 1); + #( + seed_values.push((#auth_seed_expressions).to_vec()); + )* + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, &crate::ID); + seed_values.push(vec![bump]); + (seed_values, pda) + }, + ) + }; + let authority_function = quote! { + pub fn #authority_function_name(#fn_params) -> (Vec>, solana_pubkey::Pubkey) { + #fn_body + } + }; + functions.push(authority_function); + } + } + } + + Ok(quote! { + mod __client_seed_functions { + use super::*; + #(#functions)* + } + + pub use __client_seed_functions::*; + }) +} + +#[inline(never)] +fn analyze_seed_spec_for_client( + spec: &TokenSeedSpec, + instruction_data: &[InstructionDataSpec], +) -> Result<(Vec, Vec)> { + let mut parameters = Vec::new(); + let mut expressions = Vec::new(); + + for seed in &spec.seeds { + match seed { + SeedElement::Literal(lit) => { + let value = lit.value(); + expressions.push(quote! { #value.as_bytes() }); + } + SeedElement::Expression(expr) => { + match &**expr { + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + match &*field_expr.base { + syn::Expr::Field(nested_field) => { + if let syn::Member::Named(base_name) = &nested_field.member { + if base_name == "accounts" { + if let syn::Expr::Path(path) = &*nested_field.base { + if let Some(_segment) = path.path.segments.first() { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + expressions + .push(quote! { #field_name.as_ref() }); + } else { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + expressions + .push(quote! { #field_name.as_ref() }); + } + } else { + parameters.push( + quote! { #field_name: &solana_pubkey::Pubkey }, + ); + expressions.push(quote! { #field_name.as_ref() }); + } + } else { + parameters.push( + quote! { #field_name: &solana_pubkey::Pubkey }, + ); + expressions.push(quote! { #field_name.as_ref() }); + } + } else { + parameters + .push(quote! { #field_name: &solana_pubkey::Pubkey }); + expressions.push(quote! { #field_name.as_ref() }); + } + } + syn::Expr::Path(path) => { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "data" { + if let Some(data_spec) = instruction_data + .iter() + .find(|d| d.field_name == *field_name) + { + let param_type = &data_spec.field_type; + let param_with_ref = if is_pubkey_type(param_type) { + quote! { #field_name: &#param_type } + } else { + quote! { #field_name: #param_type } + }; + parameters.push(param_with_ref); + expressions.push(quote! { #field_name.as_ref() }); + } else { + return Err(syn::Error::new_spanned( + field_name, + format!("data.{} used in seeds but no type specified", field_name), + )); + } + } else { + parameters.push( + quote! { #field_name: &solana_pubkey::Pubkey }, + ); + expressions.push(quote! { #field_name.as_ref() }); + } + } else { + parameters + .push(quote! { #field_name: &solana_pubkey::Pubkey }); + expressions.push(quote! { #field_name.as_ref() }); + } + } + _ => { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + expressions.push(quote! { #field_name.as_ref() }); + } + } + } + } + syn::Expr::MethodCall(method_call) => { + if let syn::Expr::Field(field_expr) = &*method_call.receiver { + if let syn::Member::Named(field_name) = &field_expr.member { + if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "data" { + if let Some(data_spec) = instruction_data + .iter() + .find(|d| d.field_name == *field_name) + { + let param_type = &data_spec.field_type; + let param_with_ref = if is_pubkey_type(param_type) { + quote! { #field_name: &#param_type } + } else { + quote! { #field_name: #param_type } + }; + parameters.push(param_with_ref); + + let method_name = &method_call.method; + expressions.push( + quote! { #field_name.#method_name().as_ref() }, + ); + } else { + return Err(syn::Error::new_spanned( + field_name, + format!("data.{} used in seeds but no type specified", field_name), + )); + } + } else if segment.ident == "ctx" { + // ctx.field.method() -> add field as Pubkey parameter + parameters.push( + quote! { #field_name: &solana_pubkey::Pubkey }, + ); + let method_name = &method_call.method; + expressions.push( + quote! { #field_name.#method_name().as_ref() }, + ); + } + } + } + } + } else if let syn::Expr::Path(path_expr) = &*method_call.receiver { + if let Some(ident) = path_expr.path.get_ident() { + parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); + expressions.push(quote! { #ident.as_ref() }); + } + } + } + syn::Expr::Path(path_expr) => { + if let Some(ident) = path_expr.path.get_ident() { + let ident_str = ident.to_string(); + if ident_str + .chars() + .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) + { + if ident_str == "LIGHT_CPI_SIGNER" { + expressions.push(quote! { #ident.cpi_signer.as_ref() }); + } else { + expressions.push(quote! { #ident.as_bytes() }); + } + } else { + parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); + expressions.push(quote! { #ident.as_ref() }); + } + } else { + expressions.push(quote! { (#expr).as_ref() }); + } + } + syn::Expr::Call(call_expr) => { + // Recursively map data.* to parameter names in function call arguments + fn map_client_call_arg( + arg: &syn::Expr, + instruction_data: &[InstructionDataSpec], + parameters: &mut Vec, + ) -> TokenStream { + match arg { + syn::Expr::Reference(ref_expr) => { + let inner = map_client_call_arg( + &ref_expr.expr, + instruction_data, + parameters, + ); + quote! { &#inner } + } + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "data" { + // Add parameter if needed + if let Some(data_spec) = instruction_data + .iter() + .find(|d| d.field_name == *field_name) + { + let param_type = &data_spec.field_type; + let param_with_ref = + if is_pubkey_type(param_type) { + quote! { #field_name: &#param_type } + } else { + quote! { #field_name: #param_type } + }; + if !parameters.iter().any(|p| { + p.to_string() + .contains(&field_name.to_string()) + }) { + parameters.push(param_with_ref); + } + } + return quote! { #field_name }; + } else if segment.ident == "ctx" { + // ctx.field -> add as Pubkey parameter + if !parameters.iter().any(|p| { + p.to_string() + .contains(&field_name.to_string()) + }) { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + } + return quote! { #field_name }; + } + } + } + } + quote! { #field_expr } + } + syn::Expr::MethodCall(method_call) => { + let receiver = map_client_call_arg( + &method_call.receiver, + instruction_data, + parameters, + ); + let method = &method_call.method; + let args: Vec<_> = method_call + .args + .iter() + .map(|a| { + map_client_call_arg(a, instruction_data, parameters) + }) + .collect(); + quote! { (#receiver).#method(#(#args),*) } + } + syn::Expr::Call(nested_call) => { + let func = &nested_call.func; + let args: Vec<_> = nested_call + .args + .iter() + .map(|a| { + map_client_call_arg(a, instruction_data, parameters) + }) + .collect(); + quote! { (#func)(#(#args),*) } + } + _ => quote! { #arg }, + } + } + + let mut mapped_args: Vec = Vec::new(); + for arg in &call_expr.args { + let mapped = + map_client_call_arg(arg, instruction_data, &mut parameters); + mapped_args.push(mapped); + } + let func = &call_expr.func; + expressions.push(quote! { (#func)(#(#mapped_args),*).as_ref() }); + } + syn::Expr::Reference(ref_expr) => { + let (ref_params, ref_exprs) = + analyze_seed_spec_for_client_expr(&ref_expr.expr, instruction_data)?; + parameters.extend(ref_params); + if let Some(first_expr) = ref_exprs.first() { + expressions.push(quote! { (#first_expr).as_ref() }); + } + } + _ => { + expressions.push(quote! { (#expr).as_ref() }); + } + } + } + } + } + + Ok((parameters, expressions)) +} + +#[inline(never)] +fn analyze_seed_spec_for_client_expr( + expr: &syn::Expr, + instruction_data: &[InstructionDataSpec], +) -> Result<(Vec, Vec)> { + let mut parameters = Vec::new(); + let mut expressions = Vec::new(); + + match expr { + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + if let syn::Expr::Field(nested_field) = &*field_expr.base { + if let syn::Member::Named(base_name) = &nested_field.member { + if base_name == "accounts" { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + expressions.push(quote! { #field_name }); + } else if base_name == "data" { + // Use declared instruction_data types to determine parameter type + if let Some(data_spec) = instruction_data + .iter() + .find(|d| d.field_name == *field_name) + { + let param_type = &data_spec.field_type; + let param_with_ref = if is_pubkey_type(param_type) { + quote! { #field_name: &#param_type } + } else { + quote! { #field_name: #param_type } + }; + parameters.push(param_with_ref); + expressions.push(quote! { #field_name }); + } else { + return Err(syn::Error::new_spanned( + field_name, + format!( + "data.{} used in seeds but no type specified", + field_name + ), + )); + } + } + } + } else if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "ctx" { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + expressions.push(quote! { #field_name }); + } + } + } + } + } + syn::Expr::MethodCall(method_call) => { + let (recv_params, _) = + analyze_seed_spec_for_client_expr(&method_call.receiver, instruction_data)?; + parameters.extend(recv_params); + } + syn::Expr::Call(call_expr) => { + for arg in &call_expr.args { + let (arg_params, _) = analyze_seed_spec_for_client_expr(arg, instruction_data)?; + parameters.extend(arg_params); + } + } + syn::Expr::Reference(ref_expr) => { + let (ref_params, _) = + analyze_seed_spec_for_client_expr(&ref_expr.expr, instruction_data)?; + parameters.extend(ref_params); + } + syn::Expr::Path(path_expr) => { + if let Some(ident) = path_expr.path.get_ident() { + let name = ident.to_string(); + if !(name == "ctx" + || name == "data" + || name + .chars() + .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit())) + { + parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); + } + } + } + _ => {} + } + + Ok((parameters, expressions)) +} + +fn camel_to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(c.to_lowercase().next().unwrap()); + } + result +} + +fn is_pubkey_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + let type_name = segment.ident.to_string(); + type_name == "Pubkey" || type_name.contains("Pubkey") + } else { + false + } + } else { + false + } +} diff --git a/sdk-libs/macros/src/compressible/traits.rs b/sdk-libs/macros/src/compressible/traits.rs new file mode 100644 index 0000000000..a59b97af53 --- /dev/null +++ b/sdk-libs/macros/src/compressible/traits.rs @@ -0,0 +1,256 @@ +//! Trait derivation for compressible accounts. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + DeriveInput, Expr, Field, Ident, ItemStruct, Result, Token, +}; + +use super::utils::{ + extract_fields_from_derive_input, extract_fields_from_item_struct, is_copy_type, +}; + +struct CompressAsFields { + fields: Punctuated, +} + +struct CompressAsField { + name: Ident, + value: Expr, +} + +impl Parse for CompressAsField { + fn parse(input: ParseStream) -> Result { + let name: Ident = input.parse()?; + input.parse::()?; + let value: Expr = input.parse()?; + Ok(CompressAsField { name, value }) + } +} + +impl Parse for CompressAsFields { + fn parse(input: ParseStream) -> Result { + Ok(CompressAsFields { + fields: Punctuated::parse_terminated(input)?, + }) + } +} + +/// Validates that the struct has a `compression_info` field +fn validate_compression_info_field( + fields: &Punctuated, + struct_name: &Ident, +) -> Result<()> { + let has_compression_info_field = fields.iter().any(|field| { + field + .ident + .as_ref() + .is_some_and(|name| name == "compression_info") + }); + + if !has_compression_info_field { + return Err(syn::Error::new_spanned( + struct_name, + "Struct must have a 'compression_info' field of type Option", + )); + } + + Ok(()) +} + +/// Generates the HasCompressionInfo trait implementation +fn generate_has_compression_info_impl(struct_name: &Ident) -> TokenStream { + quote! { + impl light_sdk::compressible::HasCompressionInfo for #struct_name { + fn compression_info(&self) -> &light_sdk::compressible::CompressionInfo { + self.compression_info.as_ref().expect("compression_info must be set") + } + + fn compression_info_mut(&mut self) -> &mut light_sdk::compressible::CompressionInfo { + self.compression_info.as_mut().expect("compression_info must be set") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } + } + } +} + +/// Generates field assignments for CompressAs trait, handling overrides and copy types +fn generate_compress_as_field_assignments( + fields: &Punctuated, + compress_as_fields: &Option, +) -> Vec { + let mut field_assignments = Vec::new(); + + for field in fields { + let field_name = field.ident.as_ref().unwrap(); + let field_type = &field.ty; + + if field.attrs.iter().any(|attr| attr.path().is_ident("skip")) { + continue; + } + + let has_override = compress_as_fields + .as_ref() + .is_some_and(|cas| cas.fields.iter().any(|f| &f.name == field_name)); + + if has_override { + let override_value = compress_as_fields + .as_ref() + .unwrap() + .fields + .iter() + .find(|f| &f.name == field_name) + .unwrap(); + let value = &override_value.value; + field_assignments.push(quote! { + #field_name: #value, + }); + } else if is_copy_type(field_type) { + field_assignments.push(quote! { + #field_name: self.#field_name, + }); + } else { + field_assignments.push(quote! { + #field_name: self.#field_name.clone(), + }); + } + } + + field_assignments +} + +/// Generates the CompressAs trait implementation +fn generate_compress_as_impl( + struct_name: &Ident, + field_assignments: &[TokenStream], +) -> TokenStream { + quote! { + impl light_sdk::compressible::CompressAs for #struct_name { + type Output = Self; + + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + std::borrow::Cow::Owned(Self { + compression_info: None, + #(#field_assignments)* + }) + } + } + } +} + +/// Generates size calculation fields for the Size trait +fn generate_size_fields(fields: &Punctuated) -> Vec { + let mut size_fields = Vec::new(); + + for field in fields.iter() { + let field_name = field.ident.as_ref().unwrap(); + + if field.attrs.iter().any(|attr| attr.path().is_ident("skip")) { + continue; + } + + size_fields.push(quote! { + + self.#field_name.try_to_vec().expect("Failed to serialize").len() + }); + } + + size_fields +} + +/// Generates the Size trait implementation +fn generate_size_impl(struct_name: &Ident, size_fields: &[TokenStream]) -> TokenStream { + quote! { + impl light_sdk::account::Size for #struct_name { + fn size(&self) -> usize { + // Always allocate space for Some(CompressionInfo) since it will be set during decompression + // CompressionInfo size: 1 byte (Option discriminant) + ::INIT_SPACE + let compression_info_size = 1 + ::INIT_SPACE; + compression_info_size #(#size_fields)* + } + } + } +} + +/// Generates the CompressedInitSpace trait implementation +fn generate_compressed_init_space_impl(struct_name: &Ident) -> TokenStream { + quote! { + impl light_sdk::compressible::CompressedInitSpace for #struct_name { + const COMPRESSED_INIT_SPACE: usize = Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE; + } + } +} + +pub fn derive_compress_as(input: ItemStruct) -> Result { + let struct_name = &input.ident; + let fields = extract_fields_from_item_struct(&input)?; + + let compress_as_attr = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("compress_as")); + + let compress_as_fields = if let Some(attr) = compress_as_attr { + Some(attr.parse_args::()?) + } else { + None + }; + + let field_assignments = generate_compress_as_field_assignments(fields, &compress_as_fields); + Ok(generate_compress_as_impl(struct_name, &field_assignments)) +} + +pub fn derive_has_compression_info(input: syn::ItemStruct) -> Result { + let struct_name = &input.ident; + let fields = extract_fields_from_item_struct(&input)?; + + validate_compression_info_field(fields, struct_name)?; + Ok(generate_has_compression_info_impl(struct_name)) +} + +pub fn derive_compressible(input: DeriveInput) -> Result { + let struct_name = &input.ident; + let fields = extract_fields_from_derive_input(&input)?; + + // Extract compress_as attribute + let compress_as_attr = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("compress_as")); + + let compress_as_fields = if let Some(attr) = compress_as_attr { + Some(attr.parse_args::()?) + } else { + None + }; + + // Validate compression_info field exists + validate_compression_info_field(fields, struct_name)?; + + // Generate all trait implementations using helper functions + let has_compression_info_impl = generate_has_compression_info_impl(struct_name); + + let field_assignments = generate_compress_as_field_assignments(fields, &compress_as_fields); + let compress_as_impl = generate_compress_as_impl(struct_name, &field_assignments); + + let size_fields = generate_size_fields(fields); + let size_impl = generate_size_impl(struct_name, &size_fields); + + let compressed_init_space_impl = generate_compressed_init_space_impl(struct_name); + + // Combine all implementations + Ok(quote! { + #has_compression_info_impl + #compress_as_impl + #size_impl + #compressed_init_space_impl + }) +} diff --git a/sdk-libs/macros/src/compressible/utils.rs b/sdk-libs/macros/src/compressible/utils.rs new file mode 100644 index 0000000000..3b337c232e --- /dev/null +++ b/sdk-libs/macros/src/compressible/utils.rs @@ -0,0 +1,116 @@ +//! Shared utility functions for compressible macro generation. + +use syn::{ + punctuated::Punctuated, Data, DeriveInput, Field, Fields, GenericArgument, ItemStruct, + PathArguments, Result, Token, Type, +}; + +/// Extracts named fields from an ItemStruct with proper error handling. +/// +/// Returns an error if the struct doesn't have named fields. +pub(crate) fn extract_fields_from_item_struct( + input: &ItemStruct, +) -> Result<&Punctuated> { + match &input.fields { + Fields::Named(fields) => Ok(&fields.named), + _ => Err(syn::Error::new_spanned( + input, + "Only structs with named fields are supported", + )), + } +} + +/// Extracts named fields from a DeriveInput with proper error handling. +/// +/// Returns an error if the input is not a struct with named fields. +pub(crate) fn extract_fields_from_derive_input( + input: &DeriveInput, +) -> Result<&Punctuated> { + match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => Ok(&fields.named), + _ => Err(syn::Error::new_spanned( + input, + "Only structs with named fields are supported", + )), + }, + _ => Err(syn::Error::new_spanned(input, "Only structs are supported")), + } +} + +/// Determines if a type is a Copy type (primitives, Pubkey, and Options of Copy types). +/// +/// This is used to decide whether to use `.clone()` or direct copy during field assignments. +#[inline(never)] +pub(crate) fn is_copy_type(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => { + if let Some(segment) = type_path.path.segments.last() { + let type_name = segment.ident.to_string(); + matches!( + type_name.as_str(), + "u8" | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "bool" + | "char" + | "Pubkey" + ) || (type_name == "Option" && has_copy_inner_type(&segment.arguments)) + } else { + false + } + } + Type::Array(_) => true, + _ => false, + } +} + +/// Checks if a type argument contains a Copy type (for generic types like Option). +#[inline(never)] +pub(crate) fn has_copy_inner_type(args: &PathArguments) -> bool { + match args { + PathArguments::AngleBracketed(args) => args.args.iter().any(|arg| { + if let GenericArgument::Type(ty) = arg { + is_copy_type(ty) + } else { + false + } + }), + _ => false, + } +} + +/// Determines if a type is specifically a Pubkey type. +#[inline(never)] +pub(crate) fn is_pubkey_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + segment.ident == "Pubkey" + } else { + false + } + } else { + false + } +} + +/// Generates an empty CTokenAccountVariant enum. +/// +/// This is used when no token accounts are specified in compressible instructions. +pub(crate) fn generate_empty_ctoken_enum() -> proc_macro2::TokenStream { + quote::quote! { + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize, Debug, Clone, Copy)] + #[repr(u8)] + pub enum CTokenAccountVariant {} + } +} diff --git a/sdk-libs/macros/src/compressible/variant_enum.rs b/sdk-libs/macros/src/compressible/variant_enum.rs new file mode 100644 index 0000000000..9f71a0b510 --- /dev/null +++ b/sdk-libs/macros/src/compressible/variant_enum.rs @@ -0,0 +1,247 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + Ident, Result, Token, +}; + +struct AccountTypeList { + types: Punctuated, +} + +impl Parse for AccountTypeList { + fn parse(input: ParseStream) -> Result { + Ok(AccountTypeList { + types: Punctuated::parse_terminated(input)?, + }) + } +} + +pub fn compressed_account_variant(input: TokenStream) -> Result { + let type_list = syn::parse2::(input)?; + let account_types: Vec<&Ident> = type_list.types.iter().collect(); + + if account_types.is_empty() { + return Err(syn::Error::new_spanned( + &type_list.types, + "At least one account type must be specified", + )); + } + + let account_variants = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + #name(#name), + #packed_name(#packed_name), + } + }); + + let enum_def = quote! { + #[derive(Clone, Debug, anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] + pub enum CompressedAccountVariant { + #(#account_variants)* + PackedCTokenData(light_compressed_token_sdk::compat::PackedCTokenData), + CTokenData(light_compressed_token_sdk::compat::CTokenData), + } + }; + + let first_type = account_types[0]; + let default_impl = quote! { + impl Default for CompressedAccountVariant { + fn default() -> Self { + Self::#first_type(#first_type::default()) + } + } + }; + + let hash_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#name(data) => <#name as light_hasher::DataHasher>::hash::(data), + CompressedAccountVariant::#packed_name(_) => unreachable!(), + } + }); + + let data_hasher_impl = quote! { + impl light_hasher::DataHasher for CompressedAccountVariant { + fn hash(&self) -> std::result::Result<[u8; 32], light_hasher::HasherError> { + match self { + #(#hash_match_arms)* + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + } + } + } + }; + + let light_discriminator_impl = quote! { + impl light_sdk::LightDiscriminator for CompressedAccountVariant { + const LIGHT_DISCRIMINATOR: [u8; 8] = [0; 8]; + const LIGHT_DISCRIMINATOR_SLICE: &'static [u8] = &Self::LIGHT_DISCRIMINATOR; + } + }; + + let compression_info_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#name(data) => <#name as light_sdk::compressible::HasCompressionInfo>::compression_info(data), + CompressedAccountVariant::#packed_name(_) => unreachable!(), + } + }); + + let compression_info_mut_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#name(data) => <#name as light_sdk::compressible::HasCompressionInfo>::compression_info_mut(data), + CompressedAccountVariant::#packed_name(_) => unreachable!(), + } + }); + + let compression_info_mut_opt_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#name(data) => <#name as light_sdk::compressible::HasCompressionInfo>::compression_info_mut_opt(data), + CompressedAccountVariant::#packed_name(_) => unreachable!(), + } + }); + + let set_compression_info_none_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#name(data) => <#name as light_sdk::compressible::HasCompressionInfo>::set_compression_info_none(data), + CompressedAccountVariant::#packed_name(_) => unreachable!(), + } + }); + + let has_compression_info_impl = quote! { + impl light_sdk::compressible::HasCompressionInfo for CompressedAccountVariant { + fn compression_info(&self) -> &light_sdk::compressible::CompressionInfo { + match self { + #(#compression_info_match_arms)* + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + } + } + + fn compression_info_mut(&mut self) -> &mut light_sdk::compressible::CompressionInfo { + match self { + #(#compression_info_mut_match_arms)* + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + } + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + match self { + #(#compression_info_mut_opt_match_arms)* + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + } + } + + fn set_compression_info_none(&mut self) { + match self { + #(#set_compression_info_none_match_arms)* + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + } + } + } + }; + + let size_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#name(data) => <#name as light_sdk::account::Size>::size(data), + CompressedAccountVariant::#packed_name(_) => unreachable!(), + } + }); + + let size_impl = quote! { + impl light_sdk::account::Size for CompressedAccountVariant { + fn size(&self) -> usize { + match self { + #(#size_match_arms)* + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + } + } + } + }; + + let pack_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#packed_name(_) => unreachable!(), + CompressedAccountVariant::#name(data) => CompressedAccountVariant::#packed_name(<#name as light_sdk::compressible::Pack>::pack(data, remaining_accounts)), + } + }); + + let pack_impl = quote! { + impl light_sdk::compressible::Pack for CompressedAccountVariant { + type Packed = Self; + + fn pack(&self, remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + match self { + #(#pack_match_arms)* + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(data) => { + Self::PackedCTokenData(light_compressed_token_sdk::Pack::pack(data, remaining_accounts)) + } + } + } + } + }; + + let unpack_match_arms = account_types.iter().map(|name| { + let packed_name = quote::format_ident!("Packed{}", name); + quote! { + CompressedAccountVariant::#packed_name(data) => Ok(CompressedAccountVariant::#name(<#packed_name as light_sdk::compressible::Unpack>::unpack(data, remaining_accounts)?)), + CompressedAccountVariant::#name(_) => unreachable!(), + } + }); + + let unpack_impl = quote! { + impl light_sdk::compressible::Unpack for CompressedAccountVariant { + type Unpacked = Self; + + fn unpack( + &self, + remaining_accounts: &[anchor_lang::prelude::AccountInfo], + ) -> std::result::Result { + match self { + #(#unpack_match_arms)* + Self::PackedCTokenData(_data) => Ok(self.clone()), + Self::CTokenData(_data) => unreachable!(), + } + } + } + }; + + let compressed_account_data_struct = quote! { + #[derive(Clone, Debug, anchor_lang::AnchorDeserialize, anchor_lang::AnchorSerialize)] + pub struct CompressedAccountData { + pub meta: light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + pub data: CompressedAccountVariant, + // /// Indices into remaining_accounts for seed account references (starting from seed_accounts_offset) + // pub seed_indices: Vec, + // /// Indices into remaining_accounts for authority seed references (for CTokens only) + // pub authority_indices: Vec, + } + }; + + let expanded = quote! { + #enum_def + #default_impl + #data_hasher_impl + #light_discriminator_impl + #has_compression_info_impl + #size_impl + #pack_impl + #unpack_impl + #compressed_account_data_struct + }; + + Ok(expanded) +} diff --git a/sdk-libs/macros/src/cpi_signer.rs b/sdk-libs/macros/src/cpi_signer.rs new file mode 100644 index 0000000000..87747e20b4 --- /dev/null +++ b/sdk-libs/macros/src/cpi_signer.rs @@ -0,0 +1,97 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, LitStr}; + +// TODO: review where needed. +#[allow(dead_code)] +pub fn derive_light_cpi_signer_pda(input: TokenStream) -> TokenStream { + // Parse the input - just a program ID string literal + let program_id_lit = parse_macro_input!(input as LitStr); + let program_id_str = program_id_lit.value(); + + // Compute the PDA at compile time using solana-pubkey with "cpi_authority" seed + use std::str::FromStr; + + // Parse program ID at compile time + let program_id = match solana_pubkey::Pubkey::from_str(&program_id_str) { + Ok(id) => id, + Err(_) => { + return syn::Error::new( + program_id_lit.span(), + "Invalid program ID format. Expected a base58 encoded public key", + ) + .to_compile_error() + .into(); + } + }; + + // Use fixed "cpi_authority" seed + let seeds = &[b"cpi_authority".as_slice()]; + + // Compute the PDA at compile time + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, &program_id); + + // Generate the output code with precomputed byte array and bump + let pda_bytes = pda.to_bytes(); + let bytes = pda_bytes + .iter() + .map(|b| proc_macro2::Literal::u8_unsuffixed(*b)); + + let output = quote! { + ([#(#bytes),*], #bump) + }; + + output.into() +} + +pub fn derive_light_cpi_signer(input: TokenStream) -> TokenStream { + // Parse the input - just a program ID string literal + let program_id_lit = parse_macro_input!(input as LitStr); + let program_id_str = program_id_lit.value(); + + // Compute the PDA at compile time using solana-pubkey with "cpi_authority" seed + use std::str::FromStr; + + // Parse program ID at compile time + let program_id = match solana_pubkey::Pubkey::from_str(&program_id_str) { + Ok(id) => id, + Err(_) => { + return syn::Error::new( + program_id_lit.span(), + "Invalid program ID format. Expected a base58 encoded public key", + ) + .to_compile_error() + .into(); + } + }; + + // Use fixed "cpi_authority" seed + let seeds = &[b"cpi_authority".as_slice()]; + + // Compute the PDA at compile time + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, &program_id); + + // Generate the output code with precomputed CpiSigner struct + let program_id_bytes = program_id.to_bytes(); + let pda_bytes = pda.to_bytes(); + + let program_id_literals = program_id_bytes + .iter() + .map(|b| proc_macro2::Literal::u8_unsuffixed(*b)); + let cpi_signer_literals = pda_bytes + .iter() + .map(|b| proc_macro2::Literal::u8_unsuffixed(*b)); + + let output = quote! { + { + // Use the CpiSigner type with absolute path to avoid import dependency + ::light_sdk_types::CpiSigner { + program_id: [#(#program_id_literals),*], + cpi_signer: [#(#cpi_signer_literals),*], + bump: #bump, + } + } + }; + + output.into() +} diff --git a/sdk-libs/macros/src/lib.rs b/sdk-libs/macros/src/lib.rs index 45bdf382d1..d6e7487b78 100644 --- a/sdk-libs/macros/src/lib.rs +++ b/sdk-libs/macros/src/lib.rs @@ -1,16 +1,22 @@ extern crate proc_macro; use accounts::{process_light_accounts, process_light_system_accounts}; +use discriminator::discriminator; use hasher::{derive_light_hasher, derive_light_hasher_sha}; use proc_macro::TokenStream; -use syn::{parse_macro_input, DeriveInput, ItemMod, ItemStruct}; +use syn::{parse_macro_input, DeriveInput, ItemStruct}; use traits::process_light_traits; +use utils::into_token_stream; mod account; mod accounts; +mod compressible; +mod cpi_signer; mod discriminator; mod hasher; mod program; +mod rent_sponsor; mod traits; +mod utils; /// Adds required fields to your anchor instruction for applying a zk-compressed /// state transition. @@ -45,28 +51,19 @@ mod traits; #[proc_macro_attribute] pub fn light_system_accounts(_: TokenStream, input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - - process_light_system_accounts(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + into_token_stream(process_light_system_accounts(input)) } #[proc_macro_attribute] pub fn light_accounts(_: TokenStream, input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - - match process_light_accounts(input) { - Ok(token_stream) => token_stream.into(), - Err(err) => TokenStream::from(err.to_compile_error()), - } + into_token_stream(process_light_accounts(input)) } #[proc_macro_derive(LightAccounts, attributes(light_account))] pub fn light_accounts_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - accounts::process_light_accounts_derive(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + into_token_stream(accounts::process_light_accounts_derive(input)) } /// Implements traits on the given struct required for invoking The Light system @@ -124,109 +121,89 @@ pub fn light_accounts_derive(input: TokenStream) -> TokenStream { )] pub fn light_traits_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - - match process_light_traits(input) { - Ok(token_stream) => token_stream.into(), - Err(err) => TokenStream::from(err.to_compile_error()), - } + into_token_stream(process_light_traits(input)) } #[proc_macro_derive(LightDiscriminator)] pub fn light_discriminator(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - discriminator::discriminator(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + into_token_stream(discriminator(input)) } +// /// SHA256 variant of the LightDiscriminator derive macro. +// /// +// /// This derive macro provides the same discriminator functionality as LightDiscriminator +// /// but is designed to be used with SHA256-based hashing for consistency. +// /// +// /// ## Example +// /// +// /// ```ignore +// /// use light_sdk::sha::{LightHasher, LightDiscriminator}; +// /// +// /// #[derive(LightHasher, LightDiscriminator)] +// /// pub struct LargeGameState { +// /// pub field1: u64, pub field2: u64, pub field3: u64, pub field4: u64, +// /// pub field5: u64, pub field6: u64, pub field7: u64, pub field8: u64, +// /// pub field9: u64, pub field10: u64, pub field11: u64, pub field12: u64, +// /// pub field13: u64, pub field14: u64, pub field15: u64, +// /// pub owner: Pubkey, +// /// pub authority: Pubkey, +// /// } +// /// ``` +// #[proc_macro_derive(LightDiscriminatorSha)] +// pub fn light_discriminator_sha(input: TokenStream) -> TokenStream { +// let input = parse_macro_input!(input as ItemStruct); +// discriminator_sha(input) +// .unwrap_or_else(|err| err.to_compile_error()) +// .into() +// } + /// Makes the annotated struct hashable by implementing the following traits: /// -/// - [`ToByteArray`](light_hasher::to_byte_array::ToByteArray), which makes the struct +/// - [`AsByteVec`](light_hasher::bytes::AsByteVec), which makes the struct /// convertable to a 2D byte vector. /// - [`DataHasher`](light_hasher::DataHasher), which makes the struct hashable -/// with the `hash()` method, based on the byte inputs from `ToByteArray` +/// with the `hash()` method, based on the byte inputs from `AsByteVec` /// implementation. /// /// This macro assumes that all the fields of the struct implement the /// `AsByteVec` trait. The trait is implemented by default for the most of /// standard Rust types (primitives, `String`, arrays and options carrying the /// former). If there is a field of a type not implementing the trait, there -/// are two options: -/// -/// 1. The most recommended one - annotating that type with the `light_hasher` -/// macro as well. -/// 2. Manually implementing the `ToByteArray` trait. +/// will be a compilation error. /// -/// # Attributes -/// -/// - `skip` - skips the given field, it doesn't get included neither in -/// `AsByteVec` nor `DataHasher` implementation. -/// - `hash` - makes sure that the byte value does not exceed the BN254 -/// prime field modulus, by hashing it (with Keccak) and truncating it to 31 -/// bytes. It's generally a good idea to use it on any field which is -/// expected to output more than 31 bytes. -/// -/// # Examples -/// -/// Compressed account with only primitive types as fields: +/// ## Example /// /// ```ignore -/// #[derive(LightHasher)] -/// pub struct MyCompressedAccount { -/// a: i64, -/// b: Option, -/// } -/// ``` -/// -/// Compressed account with fields which might exceed the BN254 prime field: +/// use light_sdk::LightHasher; +/// use solana_pubkey::Pubkey; /// -/// ```ignore /// #[derive(LightHasher)] -/// pub struct MyCompressedAccount { -/// a: i64 -/// b: Option, -/// #[hash] -/// c: [u8; 32], -/// #[hash] -/// d: String, +/// pub struct UserRecord { +/// pub owner: Pubkey, +/// pub name: String, +/// pub score: u64, /// } /// ``` /// -/// Compressed account with fields we want to skip: -/// -/// ```ignore -/// #[derive(LightHasher)] -/// pub struct MyCompressedAccount { -/// a: i64 -/// b: Option, -/// #[skip] -/// c: [u8; 32], -/// } -/// ``` +/// ## Hash attribute /// -/// Compressed account with a nested struct: +/// Fields marked with `#[hash]` will be hashed to field size (31 bytes) before +/// being included in the main hash calculation. This is useful for fields that +/// exceed the field size limit (like Pubkeys which are 32 bytes). /// /// ```ignore /// #[derive(LightHasher)] -/// pub struct MyCompressedAccount { -/// a: i64 -/// b: Option, -/// c: MyStruct, -/// } -/// -/// #[derive(LightHasher)] -/// pub struct MyStruct { -/// a: i32 -/// b: u32, +/// pub struct GameState { +/// #[hash] +/// pub player: Pubkey, // Will be hashed to 31 bytes +/// pub level: u32, /// } /// ``` -/// -#[proc_macro_derive(LightHasher, attributes(skip, hash))] +#[proc_macro_derive(LightHasher, attributes(hash, skip))] pub fn light_hasher(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - derive_light_hasher(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + into_token_stream(derive_light_hasher(input)) } /// SHA256 variant of the LightHasher derive macro. @@ -236,13 +213,12 @@ pub fn light_hasher(input: TokenStream) -> TokenStream { /// /// ## Example /// -/// ```rust -/// use light_sdk_macros::LightHasherSha; -/// use borsh::{BorshSerialize, BorshDeserialize}; -/// use solana_pubkey::Pubkey; +/// ```ignore +/// use light_sdk::sha::LightHasher; /// -/// #[derive(LightHasherSha, BorshSerialize, BorshDeserialize)] +/// #[derive(LightHasher)] /// pub struct GameState { +/// #[hash] /// pub player: Pubkey, // Will be hashed to 31 bytes /// pub level: u32, /// } @@ -250,33 +226,360 @@ pub fn light_hasher(input: TokenStream) -> TokenStream { #[proc_macro_derive(LightHasherSha, attributes(hash, skip))] pub fn light_hasher_sha(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - - derive_light_hasher_sha(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + into_token_stream(derive_light_hasher_sha(input)) } /// Alias of `LightHasher`. #[proc_macro_derive(DataHasher, attributes(skip, hash))] pub fn data_hasher(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - derive_light_hasher(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + into_token_stream(derive_light_hasher_sha(input)) +} + +/// Automatically implements the HasCompressionInfo trait for structs that have a +/// `compression_info: Option` field. +/// +/// This derive macro generates the required trait methods for managing compression +/// information in compressible account structs. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::compressible::{CompressionInfo, HasCompressionInfo}; +/// +/// #[derive(HasCompressionInfo)] +/// pub struct UserRecord { +/// #[skip] +/// pub compression_info: Option, +/// pub owner: Pubkey, +/// pub name: String, +/// pub score: u64, +/// } +/// ``` +/// +/// ## Requirements +/// +/// The struct must have exactly one field named `compression_info` of type +/// `Option`. The field should be marked with `#[skip]` to +/// exclude it from hashing. +#[proc_macro_derive(HasCompressionInfo)] +pub fn has_compression_info(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemStruct); + into_token_stream(compressible::traits::derive_has_compression_info(input)) +} + +/// Legacy CompressAs trait implementation (use Compressible instead). +/// +/// This derive macro allows you to specify which fields should be reset/overridden +/// during compression while keeping other fields as-is. Only the specified fields +/// are modified; all others retain their current values. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::compressible::{CompressAs, CompressionInfo, HasCompressionInfo}; +/// use light_sdk_macros::CompressAs; +/// +/// #[derive(CompressAs)] +/// #[compress_as( +/// start_time = 0, +/// end_time = None, +/// score = 0 +/// )] +/// pub struct GameSession { +/// #[skip] +/// pub compression_info: Option, +/// pub session_id: u64, +/// pub player: Pubkey, +/// pub game_type: String, +/// pub start_time: u64, +/// pub end_time: Option, +/// pub score: u64, +/// } +/// ``` +/// +/// ## Note +/// +/// Use the new `Compressible` derive instead - it includes this functionality plus more. +#[proc_macro_derive(CompressAs, attributes(compress_as))] +pub fn compress_as_derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemStruct); + into_token_stream(compressible::traits::derive_compress_as(input)) +} + +/// Adds compressible account support with automatic seed generation. +/// +/// This macro generates everything needed for compressible accounts: +/// - CompressedAccountVariant enum with all trait implementations +/// - Compress and decompress instructions with auto-generated seed derivation +/// - CTokenSeedProvider implementation for token accounts +/// - All required account structs and functions +/// +/// ## Usage +/// ```ignore +/// #[add_compressible_instructions( +/// UserRecord = ("user_record", data.owner), +/// GameSession = ("game_session", data.session_id.to_le_bytes()), +/// CTokenSigner = (is_token, "ctoken_signer", ctx.fee_payer, ctx.mint) +/// )] +/// #[program] +/// pub mod my_program { +/// // Your regular instructions here - everything else is auto-generated! +/// // CTokenAccountVariant enum is automatically generated with: +/// // - CTokenSigner = 0 +/// } +/// ``` +#[proc_macro_attribute] +pub fn add_compressible_instructions(args: TokenStream, input: TokenStream) -> TokenStream { + let module = syn::parse_macro_input!(input as syn::ItemMod); + into_token_stream(compressible::instructions::add_compressible_instructions( + args.into(), + module, + )) } +// /// Adds native compressible instructions for the specified account types +// /// +// /// This macro generates thin wrapper processor functions that you dispatch manually. +// /// +// /// ## Usage +// /// ``` +// /// #[add_native_compressible_instructions(MyPdaAccount, AnotherAccount)] +// /// pub mod compression {} +// /// ``` +// /// +// /// This generates: +// /// - Unified data structures (CompressedAccountVariant enum, etc.) +// /// - Instruction data structs (CreateCompressionConfigData, etc.) +// /// - Processor functions (create_compression_config, compress_my_pda_account, etc.) +// /// +// /// You then dispatch these in your process_instruction function. +// #[proc_macro_attribute] +// pub fn add_native_compressible_instructions(args: TokenStream, input: TokenStream) -> TokenStream { +// let input = syn::parse_macro_input!(input as syn::ItemMod); + +// native_compressible::add_native_compressible_instructions(args.into(), input) +// .unwrap_or_else(|err| err.to_compile_error()) +// .into() +// } #[proc_macro_attribute] -pub fn light_account(_: TokenStream, input: TokenStream) -> TokenStream { +pub fn account(_: TokenStream, input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - account::account(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + into_token_stream(account::account(input)) +} + +/// Automatically implements all required traits for compressible accounts. +/// +/// This derive macro generates HasCompressionInfo, Size, and CompressAs trait implementations. +/// It supports optional compress_as attribute for custom compression behavior. +/// +/// ## Example - Basic Usage +/// +/// ```ignore +/// use light_sdk_macros::Compressible; +/// use light_sdk::compressible::CompressionInfo; +/// +/// #[derive(Compressible)] +/// pub struct UserRecord { +/// #[skip] +/// pub compression_info: Option, +/// pub owner: Pubkey, +/// pub name: String, +/// pub score: u64, +/// } +/// ``` +/// +/// ## Example - Custom Compression +/// +/// ```ignore +/// #[derive(Compressible)] +/// #[compress_as(start_time = 0, end_time = None, score = 0)] +/// pub struct GameSession { +/// #[skip] +/// pub compression_info: Option, +/// pub session_id: u64, // KEPT +/// pub player: Pubkey, // KEPT +/// pub game_type: String, // KEPT +/// pub start_time: u64, // RESET to 0 +/// pub end_time: Option, // RESET to None +/// pub score: u64, // RESET to 0 +/// } +/// ``` +#[proc_macro_derive(Compressible, attributes(compress_as, light_seeds))] +pub fn compressible_derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + into_token_stream(compressible::traits::derive_compressible(input)) +} + +/// Automatically implements Pack and Unpack traits for compressible accounts. +/// +/// For types with Pubkey fields, generates a PackedXxx struct and proper packing. +/// For types without Pubkeys, generates identity Pack/Unpack implementations. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk_macros::CompressiblePack; +/// +/// #[derive(CompressiblePack)] +/// pub struct UserRecord { +/// pub compression_info: Option, +/// pub owner: Pubkey, // Will be packed as u8 index +/// pub name: String, // Kept as-is +/// pub score: u64, // Kept as-is +/// } +/// // This generates PackedUserRecord struct + Pack/Unpack implementations +/// ``` +#[proc_macro_derive(CompressiblePack)] +pub fn compressible_pack(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + into_token_stream(compressible::pack_unpack::derive_compressible_pack(input)) +} + +// DEPRECATED: compressed_account_variant macro is now integrated into add_compressible_instructions +// Use add_compressible_instructions instead for complete automation + +/// Generates complete compressible instructions with auto-generated seed derivation. +/// +/// This is a drop-in replacement for manual decompress_accounts_idempotent and +/// compress_accounts_idempotent instructions. It reads #[light_seeds(...)] attributes +/// from account types and generates complete instructions with inline seed derivation. +/// +/// ## Example +/// +/// Add #[light_seeds(...)] to your account types: +/// ```ignore +/// #[derive(Compressible, CompressiblePack)] +/// #[light_seeds(b"user_record", owner.as_ref())] +/// pub struct UserRecord { +/// pub owner: Pubkey, +/// // ... +/// } +/// +/// #[derive(Compressible, CompressiblePack)] +/// #[light_seeds(b"game_session", session_id.to_le_bytes().as_ref())] +/// pub struct GameSession { +/// pub session_id: u64, +/// // ... +/// } +/// ``` +/// +/// Then generate complete instructions: +/// ```ignore +/// compressed_account_variant_with_instructions!(UserRecord, GameSession, PlaceholderRecord); +/// ``` +/// +/// This generates: +/// - CompressedAccountVariant enum + all trait implementations +/// - Complete decompress_accounts_idempotent instruction with auto-generated seed derivation +/// - Complete compress_accounts_idempotent instruction with auto-generated seed derivation +/// - CompressedAccountData struct +/// +/// The generated instructions automatically handle seed derivation for each account type +/// without requiring manual seed function calls. +/// +/// Derive DecompressContext trait implementation. +/// +/// This generates the full DecompressContext trait implementation for +/// decompression account structs. Can be used standalone or is automatically +/// used by add_compressible_instructions. +/// +/// ## Attributes +/// - `#[pda_types(Type1, Type2, ...)]` - List of PDA account types +/// - `#[token_variant(CTokenAccountVariant)]` - The token variant enum name +/// +/// ## Example +/// +/// ```ignore +/// #[derive(Accounts, DecompressContext)] +/// #[pda_types(UserRecord, GameSession)] +/// #[token_variant(CTokenAccountVariant)] +/// pub struct DecompressAccountsIdempotent<'info> { +/// #[account(mut)] +/// pub fee_payer: Signer<'info>, +/// pub config: AccountInfo<'info>, +/// #[account(mut)] +/// pub rent_sponsor: Signer<'info>, +/// #[account(mut)] +/// pub ctoken_rent_sponsor: AccountInfo<'info>, +/// pub ctoken_program: UncheckedAccount<'info>, +/// pub ctoken_cpi_authority: UncheckedAccount<'info>, +/// pub ctoken_config: UncheckedAccount<'info>, +/// } +/// ``` +#[proc_macro_derive(DecompressContext, attributes(pda_types, token_variant))] +pub fn derive_decompress_context(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + into_token_stream(compressible::decompress_context::derive_decompress_context( + input, + )) +} + +/// Derive the CPI signer from the program ID. The program ID must be a string +/// literal. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::derive_light_cpi_signer; +/// +/// pub const LIGHT_CPI_SIGNER: CpiSigner = +/// derive_light_cpi_signer!("8Ld9pGkCNfU6A7KdKe1YrTNYJWKMCFqVHqmUvjNmER7B"); +/// ``` +#[proc_macro] +pub fn derive_light_cpi_signer(input: TokenStream) -> TokenStream { + cpi_signer::derive_light_cpi_signer(input) +} +/// Derives a Rent Sponsor PDA for a program at compile time. +/// +/// Seeds: ["rent_sponsor", ] +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::derive_light_rent_sponsor_pda; +/// +/// pub const RENT_SPONSOR_DATA: ([u8; 32], u8) = +/// derive_light_rent_sponsor_pda!("8Ld9pGkCNfU6A7KdKe1YrTNYJWKMCFqVHqmUvjNmER7B", 1); +/// ``` +#[proc_macro] +pub fn derive_light_rent_sponsor_pda(input: TokenStream) -> TokenStream { + rent_sponsor::derive_light_rent_sponsor_pda(input) } +/// Derives a complete Rent Sponsor configuration for a program at compile time. +/// +/// Returns ::light_sdk_types::RentSponsor { program_id, rent_sponsor, bump, version }. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::derive_light_rent_sponsor; +/// +/// pub const RENT_SPONSOR: ::light_sdk_types::RentSponsor = +/// derive_light_rent_sponsor!("8Ld9pGkCNfU6A7KdKe1YrTNYJWKMCFqVHqmUvjNmER7B", 1); +/// ``` +#[proc_macro] +pub fn derive_light_rent_sponsor(input: TokenStream) -> TokenStream { + rent_sponsor::derive_light_rent_sponsor(input) +} +/// Generates a Light program for the given module. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::light_program; +/// +/// #[light_program] +/// pub mod my_program { +/// pub fn my_instruction(ctx: Context) -> Result<()> { +/// // Your instruction logic here +/// Ok(()) +/// } +/// } +/// ``` #[proc_macro_attribute] pub fn light_program(_: TokenStream, input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as ItemMod); - program::program(input) - .unwrap_or_else(|err| err.to_compile_error()) - .into() + let input = parse_macro_input!(input as syn::ItemMod); + into_token_stream(program::program(input)) } diff --git a/sdk-libs/macros/src/rent_sponsor.rs b/sdk-libs/macros/src/rent_sponsor.rs new file mode 100644 index 0000000000..01606ba032 --- /dev/null +++ b/sdk-libs/macros/src/rent_sponsor.rs @@ -0,0 +1,166 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse::Parse, parse_macro_input, punctuated::Punctuated, Expr, LitInt, LitStr, Token}; + +struct Args { + program_id: LitStr, + version: Option, +} +impl Parse for Args { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let elems = Punctuated::::parse_terminated(input)?; + if elems.is_empty() { + return Err(syn::Error::new( + input.span(), + "Expected at least a program id string literal", + )); + } + // First argument must be a string literal + let program_id = match &elems[0] { + Expr::Lit(expr_lit) => { + if let syn::Lit::Str(ls) = &expr_lit.lit { + ls.clone() + } else { + return Err(syn::Error::new_spanned( + &elems[0], + "First argument must be a string literal program id", + )); + } + } + _ => { + return Err(syn::Error::new_spanned( + &elems[0], + "First argument must be a string literal program id", + )) + } + }; + // Optional second argument: version as integer literal (u16) + let version = if elems.len() > 1 { + match &elems[1] { + Expr::Lit(expr_lit) => { + if let syn::Lit::Int(li) = &expr_lit.lit { + Some(li.clone()) + } else { + return Err(syn::Error::new_spanned( + &elems[1], + "Second argument must be an integer literal (u16 version)", + )); + } + } + _ => { + return Err(syn::Error::new_spanned( + &elems[1], + "Second argument must be an integer literal (u16 version)", + )) + } + } + } else { + None + }; + Ok(Args { + program_id, + version, + }) + } +} + +/// Derives a Rent Sponsor PDA for a program at compile time. +/// +/// Seeds: ["rent_sponsor", ] +/// +/// Usage: +/// - With default version=1: +/// const DATA: ([u8; 32], u8) = derive_light_rent_sponsor_pda!("Program1111111111111111111111111111111111"); +/// - With explicit version: +/// const DATA: ([u8; 32], u8) = derive_light_rent_sponsor_pda!("Program1111111111111111111111111111111111", 2); +pub fn derive_light_rent_sponsor_pda(input: TokenStream) -> TokenStream { + let args = parse_macro_input!(input as Args); + let program_id_str = args.program_id.value(); + let version_u16: u16 = match args.version.as_ref() { + Some(lit) => lit.base10_parse::().unwrap_or(1u16), + None => 1u16, + }; + + // Parse program ID at compile time + use std::str::FromStr; + let program_id = match solana_pubkey::Pubkey::from_str(&program_id_str) { + Ok(id) => id, + Err(_) => { + return syn::Error::new( + proc_macro2::Span::call_site(), + "Invalid program ID format. Expected a base58 encoded public key", + ) + .to_compile_error() + .into(); + } + }; + + let seeds = &[b"rent_sponsor".as_slice(), &version_u16.to_le_bytes()[..]]; + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, &program_id); + + let pda_bytes = pda.to_bytes(); + let bytes = pda_bytes + .iter() + .map(|b| proc_macro2::Literal::u8_unsuffixed(*b)); + + let output = quote! { + ([#(#bytes),*], #bump) + }; + output.into() +} + +/// Derives a Rent Sponsor configuration struct at compile time. +/// +/// Returns `::light_sdk_types::RentSponsor { program_id, rent_sponsor, bump, version }`. +/// +/// Usage: +/// const RENT_SPONSOR: ::light_sdk_types::RentSponsor = +/// derive_light_rent_sponsor!("Program1111111111111111111111111111111111", 1); +pub fn derive_light_rent_sponsor(input: TokenStream) -> TokenStream { + let args = parse_macro_input!(input as Args); + let program_id_str = args.program_id.value(); + let version_u16: u16 = match args.version.as_ref() { + Some(lit) => lit.base10_parse::().unwrap_or(1u16), + None => 1u16, + }; + + // Parse program ID at compile time + use std::str::FromStr; + let program_id = match solana_pubkey::Pubkey::from_str(&program_id_str) { + Ok(id) => id, + Err(_) => { + return syn::Error::new( + proc_macro2::Span::call_site(), + "Invalid program ID format. Expected a base58 encoded public key", + ) + .to_compile_error() + .into(); + } + }; + + let seeds = &[b"rent_sponsor".as_slice(), &version_u16.to_le_bytes()[..]]; + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, &program_id); + + let program_id_bytes = program_id.to_bytes(); + let pda_bytes = pda.to_bytes(); + + let program_id_literals = program_id_bytes + .iter() + .map(|b| proc_macro2::Literal::u8_unsuffixed(*b)); + let pda_literals = pda_bytes + .iter() + .map(|b| proc_macro2::Literal::u8_unsuffixed(*b)); + + let version_lit = proc_macro2::Literal::u16_unsuffixed(version_u16); + let output = quote! { + { + ::light_sdk_types::RentSponsor { + program_id: [#(#program_id_literals),*], + rent_sponsor: [#(#pda_literals),*], + bump: #bump, + version: #version_lit, + } + } + }; + output.into() +} diff --git a/sdk-libs/macros/src/utils.rs b/sdk-libs/macros/src/utils.rs new file mode 100644 index 0000000000..b84eb1e9f8 --- /dev/null +++ b/sdk-libs/macros/src/utils.rs @@ -0,0 +1,19 @@ +//! Shared utility functions for proc macros. + +use proc_macro::TokenStream; +use syn::Result; + +/// Converts a `syn::Result` to `proc_macro::TokenStream`. +/// +/// ## Usage +/// ```ignore +/// #[proc_macro_derive(MyMacro)] +/// pub fn my_macro(input: TokenStream) -> TokenStream { +/// let input = parse_macro_input!(input as DeriveInput); +/// into_token_stream(some_function(input)) +/// } +/// ``` +#[inline] +pub(crate) fn into_token_stream(result: Result) -> TokenStream { + result.unwrap_or_else(|err| err.to_compile_error()).into() +} diff --git a/sdk-libs/program-test/Cargo.toml b/sdk-libs/program-test/Cargo.toml index a4e0a32279..1963702f07 100644 --- a/sdk-libs/program-test/Cargo.toml +++ b/sdk-libs/program-test/Cargo.toml @@ -7,8 +7,7 @@ edition = "2021" [features] default = [] -devenv = ["v2", "light-client/devenv", "light-prover-client/devenv", "dep:account-compression", "dep:light-compressed-token", "dep:light-ctoken-types", "dep:light-compressible", "dep:light-registry", "dep:light-batched-merkle-tree", "dep:light-concurrent-merkle-tree"] -v2 = ["light-client/v2"] +devenv = ["light-client/devenv", "light-prover-client/devenv", "dep:account-compression", "dep:light-compressed-token", "dep:light-ctoken-types", "dep:light-compressible", "dep:light-registry", "dep:light-batched-merkle-tree", "dep:light-concurrent-merkle-tree"] [dependencies] light-sdk = { workspace = true, features = ["anchor"] } @@ -20,7 +19,7 @@ light-concurrent-merkle-tree = { workspace = true, optional = true } light-hasher = { workspace = true, features = ["poseidon", "sha256", "keccak", "std"] } light-ctoken-types = { workspace = true, optional = true } light-compressible = { workspace = true, optional = true } -light-compressed-token-sdk = { workspace = true } +light-compressed-token-sdk = { workspace = true, optional = true } light-compressed-account = { workspace = true, features = ["anchor", "poseidon"] } light-batched-merkle-tree = { workspace = true, features = ["test-only"], optional = true } light-event = { workspace = true } diff --git a/sdk-libs/program-test/src/accounts/initialize.rs b/sdk-libs/program-test/src/accounts/initialize.rs index 37700d2393..9b793881c7 100644 --- a/sdk-libs/program-test/src/accounts/initialize.rs +++ b/sdk-libs/program-test/src/accounts/initialize.rs @@ -17,7 +17,7 @@ use solana_sdk::{ signature::{Keypair, Signer}, }; -#[cfg(feature = "v2")] +#[cfg(feature = "devenv")] use super::{ address_tree_v2::create_batch_address_merkle_tree, state_tree_v2::create_batched_state_merkle_tree, @@ -165,7 +165,7 @@ pub async fn initialize_accounts( // ) // .await?; } - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] if let Some(v2_state_tree_config) = _v2_state_tree_config { create_batched_state_merkle_tree( &keypairs.governance_authority, @@ -178,7 +178,7 @@ pub async fn initialize_accounts( ) .await?; } - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] if let Some(params) = _v2_address_tree_config { create_batch_address_merkle_tree( context, diff --git a/sdk-libs/program-test/src/compressible.rs b/sdk-libs/program-test/src/compressible.rs index 64d4e2e1f4..5162233028 100644 --- a/sdk-libs/program-test/src/compressible.rs +++ b/sdk-libs/program-test/src/compressible.rs @@ -8,15 +8,19 @@ use borsh::BorshDeserialize; #[cfg(feature = "devenv")] use light_client::rpc::{Rpc, RpcError}; #[cfg(feature = "devenv")] -use light_compressible::rent::SLOTS_PER_EPOCH; +use light_compressible::config::CompressibleConfig as CtokenCompressibleConfig; +#[cfg(feature = "devenv")] +use light_compressible::rent::RentConfig; #[cfg(feature = "devenv")] -use light_compressible::{config::CompressibleConfig, rent::RentConfig}; +use light_compressible::rent::SLOTS_PER_EPOCH; #[cfg(feature = "devenv")] use light_ctoken_types::{ state::{CToken, ExtensionStruct}, COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, }; #[cfg(feature = "devenv")] +use light_sdk::compressible::CompressibleConfig as CpdaCompressibleConfig; +#[cfg(feature = "devenv")] use solana_pubkey::Pubkey; #[cfg(feature = "devenv")] @@ -47,14 +51,14 @@ pub struct FundingPoolConfig { #[cfg(feature = "devenv")] impl FundingPoolConfig { pub fn new(version: u16) -> Self { - let config = CompressibleConfig::new_ctoken( + let config = CtokenCompressibleConfig::new_ctoken( version, true, Pubkey::default(), Pubkey::default(), RentConfig::default(), ); - let compressible_config = CompressibleConfig::derive_pda( + let compressible_config = CtokenCompressibleConfig::derive_pda( &pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX"), version, ) @@ -177,29 +181,31 @@ pub async fn auto_compress_program_pdas( let payer = rpc.get_payer().insecure_clone(); - let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; - let cfg_acc = rpc - .get_account(config_pda) - .await? - .ok_or_else(|| RpcError::CustomError("compressible config not found".into()))?; - let cfg = CompressibleConfig::deserialize(&mut &cfg_acc.data[..]) + let config_pda = CpdaCompressibleConfig::derive_pda(&program_id, 0).0; + + let cfg_acc_opt = rpc.get_account(config_pda).await?; + let Some(cfg_acc) = cfg_acc_opt else { + // Config not found for this program; skip gracefully + return Ok(()); + }; + let cfg = CpdaCompressibleConfig::try_from_slice(&cfg_acc.data) .map_err(|e| RpcError::CustomError(format!("config deserialize: {e:?}")))?; let rent_sponsor = cfg.rent_sponsor; + // TODO: add coverage for external compression_authority + let compression_authority = payer.pubkey(); let address_tree = cfg.address_space[0]; let program_accounts = rpc.context.get_program_accounts(&program_id); + if program_accounts.is_empty() { return Ok(()); } - let output_state_tree_info = rpc - .get_random_state_tree_info() - .map_err(|e| RpcError::CustomError(format!("no state tree: {e:?}")))?; - let program_metas = vec![ AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(config_pda, false), AccountMeta::new(rent_sponsor, false), + AccountMeta::new_readonly(compression_authority, false), ]; const BATCH_SIZE: usize = 5; @@ -210,29 +216,13 @@ pub async fn auto_compress_program_pdas( { chunk.push((pubkey, account)); if chunk.len() == BATCH_SIZE { - try_compress_chunk( - rpc, - &program_id, - &chunk, - &program_metas, - &address_tree, - output_state_tree_info, - ) - .await; + try_compress_chunk(rpc, &program_id, &chunk, &program_metas, &address_tree).await; chunk.clear(); } } if !chunk.is_empty() { - try_compress_chunk( - rpc, - &program_id, - &chunk, - &program_metas, - &address_tree, - output_state_tree_info, - ) - .await; + try_compress_chunk(rpc, &program_id, &chunk, &program_metas, &address_tree).await; } Ok(()) @@ -245,57 +235,56 @@ async fn try_compress_chunk( chunk: &[(Pubkey, solana_sdk::account::Account)], program_metas: &[solana_instruction::AccountMeta], address_tree: &Pubkey, - output_state_tree_info: light_client::indexer::TreeInfo, ) { use light_client::indexer::Indexer; use light_compressed_account::address::derive_address; - use light_compressible_client::CompressibleInstruction; + use light_compressible_client::compressible_instruction; use solana_sdk::signature::Signer; - let mut pdas = Vec::with_capacity(chunk.len()); - let mut accounts_to_compress = Vec::with_capacity(chunk.len()); - let mut hashes = Vec::with_capacity(chunk.len()); + // Attempt compression per-account idempotently. for (pda, acc) in chunk.iter() { let addr = derive_address( &pda.to_bytes(), &address_tree.to_bytes(), &program_id.to_bytes(), ); - if let Ok(resp) = rpc.get_compressed_account(addr, None).await { - if let Some(cacc) = resp.value { - pdas.push(*pda); - accounts_to_compress.push(acc.clone()); - hashes.push(cacc.hash); - } - } - } - if pdas.is_empty() { - return; - } - let proof_with_context = match rpc.get_validity_proof(hashes, vec![], None).await { - Ok(r) => r.value, - Err(_) => return, - }; + // Only proceed if a compressed account exists + let Ok(resp) = rpc.get_compressed_account(addr, None).await else { + continue; + }; + let Some(cacc) = resp.value else { + continue; + }; + + // Fetch proof for this single account hash + let Ok(proof_with_context) = rpc + .get_validity_proof(vec![cacc.hash], vec![], None) + .await + .map(|r| r.value) + else { + continue; + }; + + // Build single-PDA compress instruction + let Ok(ix) = compressible_instruction::compress_accounts_idempotent( + program_id, + &compressible_instruction::COMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &[*pda], + std::slice::from_ref(acc), + program_metas, + proof_with_context, + ) + .map_err(|e| e.to_string()) else { + continue; + }; - let signer_seeds: Vec>> = (0..pdas.len()).map(|_| Vec::new()).collect(); - - let ix_res = CompressibleInstruction::compress_accounts_idempotent( - program_id, - &CompressibleInstruction::COMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, - &pdas, - &accounts_to_compress, - program_metas, - signer_seeds, - proof_with_context, - output_state_tree_info, - ) - .map_err(|e| e.to_string()); - if let Ok(ix) = ix_res { let payer = rpc.get_payer().insecure_clone(); let payer_pubkey = payer.pubkey(); + + // Ignore errors to continue compressing other PDAs let _ = rpc - .create_and_send_transaction(&[ix], &payer_pubkey, &[&payer]) + .create_and_send_transaction(std::slice::from_ref(&ix), &payer_pubkey, &[&payer]) .await; } } diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index bc5a07e6e6..ff5421c79e 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -17,7 +17,7 @@ use async_trait::async_trait; use borsh::BorshDeserialize; #[cfg(feature = "devenv")] use light_batched_merkle_tree::merkle_tree::BatchedMerkleTreeAccount; -#[cfg(feature = "v2")] +#[cfg(feature = "devenv")] use light_client::indexer::MerkleProofWithContext; #[cfg(feature = "devenv")] use light_client::rpc::{Rpc, RpcError}; @@ -452,7 +452,7 @@ impl Indexer for TestIndexer { new_addresses_with_trees: Vec, _config: Option, ) -> Result, IndexerError> { - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] { // V2 implementation with queue handling let mut state_merkle_tree_pubkeys = Vec::new(); @@ -598,7 +598,7 @@ impl Indexer for TestIndexer { }) } - #[cfg(not(feature = "v2"))] + #[cfg(not(feature = "devenv"))] { // V1 implementation - direct call to V1 logic let result = self @@ -622,9 +622,9 @@ impl Indexer for TestIndexer { _input_queue_limit: Option, _config: Option, ) -> Result, IndexerError> { - #[cfg(not(feature = "v2"))] + #[cfg(not(feature = "devenv"))] unimplemented!("get_queue_elements"); - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] { let merkle_tree_pubkey = _merkle_tree_pubkey; let output_queue_start_index = _output_queue_start_index.unwrap_or(0); @@ -870,9 +870,9 @@ impl Indexer for TestIndexer { _merkle_tree_pubkey: [u8; 32], _config: Option, ) -> Result>, IndexerError> { - #[cfg(not(feature = "v2"))] + #[cfg(not(feature = "devenv"))] unimplemented!("get_subtrees"); - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] { let merkle_tree_pubkey = Pubkey::new_from_array(_merkle_tree_pubkey); let address_tree_bundle = self @@ -918,9 +918,9 @@ impl Indexer for TestIndexer { _start_offset: Option, _config: Option, ) -> Result, IndexerError> { - #[cfg(not(feature = "v2"))] + #[cfg(not(feature = "devenv"))] unimplemented!("get_address_queue_with_proofs"); - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] { use light_client::indexer::AddressQueueIndex; let merkle_tree_pubkey = _merkle_tree_pubkey; diff --git a/sdk-libs/program-test/src/program_test/compressible_setup.rs b/sdk-libs/program-test/src/program_test/compressible_setup.rs index ed187d34a5..43758a1405 100644 --- a/sdk-libs/program-test/src/program_test/compressible_setup.rs +++ b/sdk-libs/program-test/src/program_test/compressible_setup.rs @@ -4,7 +4,7 @@ //! including mock program data setup and configuration management. use light_client::rpc::{Rpc, RpcError}; -use light_compressible_client::CompressibleInstruction; +use light_compressible_client::compressible_instruction; use solana_sdk::{ bpf_loader_upgradeable, pubkey::Pubkey, @@ -65,7 +65,6 @@ pub fn setup_mock_program_data( /// * `payer` - The transaction fee payer /// * `program_id` - The program to initialize config for /// * `authority` - The config authority (can be same as payer) -/// * `compression_delay` - Number of slots to wait before compression /// * `rent_sponsor` - Where to send rent from compressed accounts /// * `address_space` - List of address trees for this program /// @@ -77,7 +76,6 @@ pub async fn initialize_compression_config( payer: &Keypair, program_id: &Pubkey, authority: &Keypair, - compression_delay: u32, rent_sponsor: Pubkey, address_space: Vec, discriminator: &[u8], @@ -89,12 +87,11 @@ pub async fn initialize_compression_config( )); } - let instruction = CompressibleInstruction::initialize_compression_config( + let instruction = compressible_instruction::initialize_compression_config( program_id, discriminator, &payer.pubkey(), &authority.pubkey(), - compression_delay, rent_sponsor, address_space, config_bump, @@ -117,7 +114,6 @@ pub async fn initialize_compression_config( /// * `payer` - The transaction fee payer /// * `program_id` - The program to update config for /// * `authority` - The current config authority -/// * `new_compression_delay` - New compression delay (optional) /// * `new_rent_sponsor` - New rent recipient (optional) /// * `new_address_space` - New address space list (optional) /// * `new_update_authority` - New authority (optional) @@ -130,17 +126,15 @@ pub async fn update_compression_config( payer: &Keypair, program_id: &Pubkey, authority: &Keypair, - new_compression_delay: Option, new_rent_sponsor: Option, new_address_space: Option>, new_update_authority: Option, discriminator: &[u8], ) -> Result { - let instruction = CompressibleInstruction::update_compression_config( + let instruction = compressible_instruction::update_compression_config( program_id, discriminator, &authority.pubkey(), - new_compression_delay, new_rent_sponsor, new_address_space, new_update_authority, diff --git a/sdk-libs/program-test/src/program_test/config.rs b/sdk-libs/program-test/src/program_test/config.rs index 50eccb7e1a..2c351b707d 100644 --- a/sdk-libs/program-test/src/program_test/config.rs +++ b/sdk-libs/program-test/src/program_test/config.rs @@ -68,7 +68,7 @@ impl ProgramTestConfig { } } - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] pub fn new_v2( with_prover: bool, additional_programs: Option>, @@ -134,7 +134,7 @@ impl Default for ProgramTestConfig { }, with_prover: true, #[cfg(feature = "devenv")] - auto_register_custom_programs_for_pda_compression: false, + auto_register_custom_programs_for_pda_compression: true, #[cfg(feature = "devenv")] skip_second_v1_tree: false, #[cfg(feature = "devenv")] diff --git a/sdk-libs/program-test/src/program_test/light_program_test.rs b/sdk-libs/program-test/src/program_test/light_program_test.rs index 67a35fe213..08b4680f26 100644 --- a/sdk-libs/program-test/src/program_test/light_program_test.rs +++ b/sdk-libs/program-test/src/program_test/light_program_test.rs @@ -34,7 +34,7 @@ pub struct LightProgramTest { pub payer: Keypair, pub transaction_counter: usize, #[cfg(feature = "devenv")] - pub auto_compress_programs: Vec, + pub auto_mine_cold_state_programs: Vec, } impl LightProgramTest { @@ -80,7 +80,7 @@ impl LightProgramTest { config: config.clone(), transaction_counter: 0, #[cfg(feature = "devenv")] - auto_compress_programs: Vec::new(), + auto_mine_cold_state_programs: Vec::new(), }; let keypairs = TestKeypairs::program_test_default(); @@ -164,7 +164,9 @@ impl LightProgramTest { if auto_register { if let Some(programs) = additional_programs { for (_, pid) in programs.into_iter() { - context.register_auto_compression(pid); + if !context.auto_mine_cold_state_programs.contains(&pid) { + context.auto_mine_cold_state_programs.push(pid); + } } } } @@ -424,10 +426,9 @@ impl LightProgramTest { } #[cfg(feature = "devenv")] - pub fn register_auto_compression(&mut self, program_id: solana_sdk::pubkey::Pubkey) { - if !self.auto_compress_programs.contains(&program_id) { - self.auto_compress_programs.push(program_id); - } + pub fn disable_cold_state_mining(&mut self, program_id: solana_sdk::pubkey::Pubkey) { + self.auto_mine_cold_state_programs + .retain(|&pid| pid != program_id); } } diff --git a/sdk-libs/program-test/src/program_test/rpc.rs b/sdk-libs/program-test/src/program_test/rpc.rs index 1578aa0464..225445fef4 100644 --- a/sdk-libs/program-test/src/program_test/rpc.rs +++ b/sdk-libs/program-test/src/program_test/rpc.rs @@ -245,7 +245,7 @@ impl Rpc for LightProgramTest { /// Fetch the latest state tree addresses from the cluster. async fn get_latest_active_state_trees(&mut self) -> Result, RpcError> { - #[cfg(not(feature = "v2"))] + #[cfg(not(feature = "devenv"))] return Ok(self .test_accounts .v1_state_trees @@ -253,7 +253,7 @@ impl Rpc for LightProgramTest { .copied() .map(|tree| tree.into()) .collect()); - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] return Ok(self .test_accounts .v2_state_trees @@ -264,7 +264,7 @@ impl Rpc for LightProgramTest { /// Fetch the latest state tree addresses from the cluster. fn get_state_tree_infos(&self) -> Vec { - #[cfg(not(feature = "v2"))] + #[cfg(not(feature = "devenv"))] return self .test_accounts .v1_state_trees @@ -272,7 +272,7 @@ impl Rpc for LightProgramTest { .copied() .map(|tree| tree.into()) .collect(); - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] return self .test_accounts .v2_state_trees @@ -286,7 +286,7 @@ impl Rpc for LightProgramTest { fn get_random_state_tree_info(&self) -> Result { use rand::Rng; let mut rng = rand::thread_rng(); - #[cfg(not(feature = "v2"))] + #[cfg(not(feature = "devenv"))] { if self.test_accounts.v1_state_trees.is_empty() { return Err(RpcError::NoStateTreesAvailable); @@ -295,7 +295,7 @@ impl Rpc for LightProgramTest { [rng.gen_range(0..self.test_accounts.v1_state_trees.len())] .into()) } - #[cfg(feature = "v2")] + #[cfg(feature = "devenv")] { if self.test_accounts.v2_state_trees.is_empty() { return Err(RpcError::NoStateTreesAvailable); diff --git a/sdk-libs/program-test/src/program_test/test_rpc.rs b/sdk-libs/program-test/src/program_test/test_rpc.rs index 8660095ab3..392924aeb0 100644 --- a/sdk-libs/program-test/src/program_test/test_rpc.rs +++ b/sdk-libs/program-test/src/program_test/test_rpc.rs @@ -149,7 +149,7 @@ impl TestRpc for LightProgramTest { } /// Warps current slot forward by slots. - /// Claims and compresses compressible ctoken accounts. + /// Claims and compresses compressible ctoken accounts and program PDAs (auto compress). #[cfg(feature = "devenv")] async fn warp_slot_forward(&mut self, slot: Slot) -> Result<(), RpcError> { let mut current_slot = self.context.get_sysvar::().slot; @@ -157,7 +157,7 @@ impl TestRpc for LightProgramTest { self.context.warp_to_slot(current_slot); let mut store = CompressibleAccountStore::new(); crate::compressible::claim_and_compress(self, &mut store).await?; - for program_id in self.auto_compress_programs.clone() { + for program_id in self.auto_mine_cold_state_programs.clone() { crate::compressible::auto_compress_program_pdas(self, program_id).await?; } Ok(()) diff --git a/sdk-libs/sdk-types/src/lib.rs b/sdk-libs/sdk-types/src/lib.rs index b31d17f4e2..b63eebc639 100644 --- a/sdk-libs/sdk-types/src/lib.rs +++ b/sdk-libs/sdk-types/src/lib.rs @@ -18,3 +18,11 @@ use anchor_lang::{AnchorDeserialize, AnchorSerialize}; use borsh::{BorshDeserialize as AnchorDeserialize, BorshSerialize as AnchorSerialize}; pub use constants::*; pub use light_compressed_account::CpiSigner; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, AnchorSerialize, AnchorDeserialize)] +pub struct RentSponsor { + pub program_id: [u8; 32], + pub rent_sponsor: [u8; 32], + pub bump: u8, + pub version: u16, +} diff --git a/sdk-libs/sdk/Cargo.toml b/sdk-libs/sdk/Cargo.toml index 024adcd982..8c8379b705 100644 --- a/sdk-libs/sdk/Cargo.toml +++ b/sdk-libs/sdk/Cargo.toml @@ -12,11 +12,12 @@ name = "light_sdk" [features] default = [] -idl-build = ["anchor-lang/idl-build"] +idl-build = ["anchor-lang/idl-build", "anchor"] anchor = [ "anchor-lang", "light-compressed-account/anchor", "light-sdk-types/anchor", + "light-compressible/anchor", ] v2 = ["light-sdk-types/v2"] cpi-context = ["light-sdk-types/cpi-context"] @@ -55,6 +56,7 @@ light-account-checks = { workspace = true, features = ["solana"] } light-zero-copy = { workspace = true } light-concurrent-merkle-tree = { workspace = true, optional = true } light-ctoken-types = { workspace = true } +light-compressible = { workspace = true } [dev-dependencies] num-bigint = { workspace = true } diff --git a/sdk-libs/sdk/src/compressible/compress_account.rs b/sdk-libs/sdk/src/compressible/compress_account.rs index d0b8e7dc32..77e7c46a6a 100644 --- a/sdk-libs/sdk/src/compressible/compress_account.rs +++ b/sdk-libs/sdk/src/compressible/compress_account.rs @@ -1,11 +1,12 @@ use light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo; +use light_compressible::rent::AccountRentState; use light_hasher::DataHasher; use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; use solana_account_info::AccountInfo; use solana_clock::Clock; use solana_msg::msg; use solana_pubkey::Pubkey; -use solana_sysvar::Sysvar; +use solana_sysvar::{rent::Rent, Sysvar}; use crate::{ account::sha::LightAccount, @@ -15,7 +16,6 @@ use crate::{ instruction::account_meta::CompressedAccountMeta, AnchorDeserialize, AnchorSerialize, LightDiscriminator, ProgramError, }; - /// Prepare account for compression. /// /// # Arguments @@ -24,7 +24,6 @@ use crate::{ /// * `account_data` - Mutable reference to the deserialized account data /// * `compressed_account_meta` - Metadata for the compressed account /// * `cpi_accounts` - Accounts for CPI to light system program -/// * `compression_delay` - Minimum slots before compression allowed /// * `address_space` - Address space for validation #[cfg(feature = "v2")] pub fn prepare_account_for_compression<'info, A>( @@ -33,7 +32,6 @@ pub fn prepare_account_for_compression<'info, A>( account_data: &mut A, compressed_account_meta: &CompressedAccountMetaNoLamportsNoAddress, cpi_accounts: &CpiAccounts<'_, 'info>, - compression_delay: &u32, address_space: &[Pubkey], ) -> std::result::Result where @@ -68,12 +66,35 @@ where }; let current_slot = Clock::get()?.slot; - let last_written_slot = account_data.compression_info().last_written_slot(); - - if current_slot < last_written_slot + *compression_delay as u64 { + // Rent-function gating: account must be compressible w.r.t. rent function (current+next epoch) + let bytes = account_info.data_len() as u64; + let current_lamports = account_info.lamports(); + let rent_exemption_lamports = Rent::get() + .map_err(|_| LightSdkError::ConstraintViolation)? + .minimum_balance(bytes as usize); + let ci = account_data.compression_info(); + let last_claimed_slot = ci.last_claimed_slot(); + let rent_cfg = ci.rent_config; + let state = AccountRentState { + num_bytes: bytes, + current_slot, + current_lamports, + last_claimed_slot, + }; + if state + .is_compressible(&rent_cfg, rent_exemption_lamports) + .is_none() + { msg!( - "prepare_account_for_compression failed: Cannot compress yet. {} slots remaining", - (last_written_slot + *compression_delay as u64).saturating_sub(current_slot) + "prepare_account_for_compression failed: \ + Account is not compressible by rent function. \ + slot: {}, lamports: {}, bytes: {}, rent_exemption_lamports: {}, last_claimed_slot: {}, rent_config: {:?}", + current_slot, + current_lamports, + bytes, + rent_exemption_lamports, + last_claimed_slot, + rent_cfg ); return Err(LightSdkError::ConstraintViolation.into()); } diff --git a/sdk-libs/sdk/src/compressible/compress_account_on_init.rs b/sdk-libs/sdk/src/compressible/compress_account_on_init.rs index 1bf9c1ad89..e5af904bc9 100644 --- a/sdk-libs/sdk/src/compressible/compress_account_on_init.rs +++ b/sdk-libs/sdk/src/compressible/compress_account_on_init.rs @@ -35,6 +35,7 @@ use crate::{ pub fn prepare_compressed_account_on_init<'info, A>( account_info: &AccountInfo<'info>, account_data: &mut A, + compression_config: &crate::compressible::CompressibleConfig, address: [u8; 32], new_address_param: NewAddressParamsAssignedPacked, output_state_tree_index: u8, @@ -65,15 +66,20 @@ where msg!("Address tree {} not in allowed address space", tree); return Err(LightSdkError::ConstraintViolation.into()); } - *account_data.compression_info_mut_opt() = - Some(super::compression_info::CompressionInfo::new_decompressed()?); + // Initialize CompressionInfo from config + // Note: Rent sponsor is not stored per-account; compression always sends rent to config's rent_sponsor + use solana_sysvar::{clock::Clock, Sysvar}; + let current_slot = Clock::get()?.slot; + *account_data.compression_info_mut_opt() = Some( + super::compression_info::CompressionInfo::new_from_config(compression_config, current_slot), + ); if with_data { account_data.compression_info_mut().set_compressed(); } else { account_data .compression_info_mut() - .bump_last_written_slot()?; + .bump_last_claimed_slot()?; } { let mut data = account_info diff --git a/sdk-libs/sdk/src/compressible/compress_runtime.rs b/sdk-libs/sdk/src/compressible/compress_runtime.rs new file mode 100644 index 0000000000..fc9b67a736 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/compress_runtime.rs @@ -0,0 +1,109 @@ +//! Runtime for compress_accounts_idempotent instruction. +use light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo; +use light_sdk_types::{ + instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, CpiSigner, +}; +use solana_account_info::AccountInfo; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +pub trait CompressContext<'info> { + fn fee_payer(&self) -> &AccountInfo<'info>; + fn config(&self) -> &AccountInfo<'info>; + fn rent_sponsor(&self) -> &AccountInfo<'info>; + fn compression_authority(&self) -> &AccountInfo<'info>; + + fn compress_pda_account( + &self, + account_info: &AccountInfo<'info>, + meta: &CompressedAccountMetaNoLamportsNoAddress, + cpi_accounts: &crate::cpi::v2::CpiAccounts<'_, 'info>, + compression_config: &crate::compressible::CompressibleConfig, + program_id: &Pubkey, + ) -> Result, ProgramError>; +} + +#[inline(never)] +#[allow(clippy::too_many_arguments)] +pub fn process_compress_pda_accounts_idempotent<'info, Ctx>( + ctx: &Ctx, + remaining_accounts: &[AccountInfo<'info>], + compressed_accounts: Vec, + system_accounts_offset: u8, + cpi_signer: CpiSigner, + program_id: &Pubkey, +) -> Result<(), ProgramError> +where + Ctx: CompressContext<'info>, +{ + use crate::cpi::{ + v2::{CpiAccounts, LightSystemProgramCpi}, + InvokeLightSystemProgram, LightCpiInstruction, + }; + + let proof = crate::instruction::ValidityProof::new(None); + + let compression_config = + crate::compressible::CompressibleConfig::load_checked(ctx.config(), program_id)?; + + if *ctx.rent_sponsor().key != compression_config.rent_sponsor { + return Err(ProgramError::Custom(0)); + } + if *ctx.compression_authority().key != compression_config.compression_authority { + return Err(ProgramError::Custom(0)); + } + + let cpi_accounts = CpiAccounts::new( + ctx.fee_payer(), + &remaining_accounts[system_accounts_offset as usize..], + cpi_signer, + ); + + let mut compressed_pda_infos: Vec = + Vec::with_capacity(compressed_accounts.len()); + let mut pda_indices_to_close: Vec = Vec::with_capacity(compressed_accounts.len()); + + let system_accounts_start = cpi_accounts.system_accounts_end_offset(); + let all_post_system = &cpi_accounts.to_account_infos()[system_accounts_start..]; + + // PDAs are at the end of remaining_accounts, after all the merkle tree/queue accounts + let pda_start_in_all_accounts = all_post_system.len() - compressed_accounts.len(); + let solana_accounts = &all_post_system[pda_start_in_all_accounts..]; + + for (i, account_info) in solana_accounts.iter().enumerate() { + if account_info.data_is_empty() { + continue; + } + + if account_info.owner != program_id { + continue; + } + + let meta = compressed_accounts[i]; + + if let Some(compressed_info) = ctx.compress_pda_account( + account_info, + &meta, + &cpi_accounts, + &compression_config, + program_id, + )? { + compressed_pda_infos.push(compressed_info); + pda_indices_to_close.push(i); + } + } + + if !compressed_pda_infos.is_empty() { + LightSystemProgramCpi::new_cpi(cpi_signer, proof) + .with_account_infos(&compressed_pda_infos) + .invoke(cpi_accounts.clone())?; + + for idx in pda_indices_to_close { + let mut info = solana_accounts[idx].clone(); + crate::compressible::close::close(&mut info, ctx.rent_sponsor().clone()) + .map_err(ProgramError::from)?; + } + } + + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/compression_info.rs b/sdk-libs/sdk/src/compressible/compression_info.rs index 3027385bd7..35d9e6f9d3 100644 --- a/sdk-libs/sdk/src/compressible/compression_info.rs +++ b/sdk-libs/sdk/src/compressible/compression_info.rs @@ -1,11 +1,13 @@ use std::borrow::Cow; +use light_compressible::rent::{RentConfig, RentConfigTrait}; use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; use solana_account_info::AccountInfo; use solana_clock::Clock; +use solana_pubkey::Pubkey; use solana_sysvar::Sysvar; -use crate::{instruction::PackedAccounts, AnchorDeserialize, AnchorSerialize}; +use crate::{instruction::PackedAccounts, AnchorDeserialize, AnchorSerialize, ProgramError}; /// Replace 32-byte Pubkeys with 1-byte indices to save space. /// If your type has no Pubkeys, just return self. @@ -58,7 +60,15 @@ pub trait CompressAs { #[derive(Debug, Clone, Default, AnchorSerialize, AnchorDeserialize)] pub struct CompressionInfo { - pub last_written_slot: u64, + /// Version of the compressible config used to initialize this account. + pub config_version: u16, + /// Lamports to top up on each write (from config, stored per-account to avoid passing config on every write) + pub lamports_per_write: u32, + /// Slot when rent was last claimed (epoch boundary accounting). + pub last_claimed_slot: u64, + /// Rent function parameters for determining compressibility/claims. + pub rent_config: RentConfig, + /// Account compression state. pub state: CompressionState, } @@ -71,24 +81,50 @@ pub enum CompressionState { } impl CompressionInfo { + /// Create a new CompressionInfo initialized from a compressible config. + /// + /// Rent sponsor is always the config's rent_sponsor (not stored per-account). + /// This means rent always flows to the protocol's rent pool upon compression, + /// regardless of who paid for account creation. + pub fn new_from_config( + cfg: &crate::compressible::CompressibleConfig, + current_slot: u64, + ) -> Self { + Self { + config_version: cfg.version as u16, + lamports_per_write: cfg.write_top_up, + last_claimed_slot: current_slot, + rent_config: cfg.rent_config, + state: CompressionState::Decompressed, + } + } + + /// Backward-compat constructor used by older call sites; initializes minimal fields. + /// Rent will flow to config's rent_sponsor upon compression. pub fn new_decompressed() -> Result { Ok(Self { - last_written_slot: Clock::get()?.slot, + config_version: 0, + lamports_per_write: 0, + last_claimed_slot: Clock::get()?.slot, + rent_config: RentConfig::default(), state: CompressionState::Decompressed, }) } - pub fn bump_last_written_slot(&mut self) -> Result<(), crate::ProgramError> { - self.last_written_slot = Clock::get()?.slot; + /// Update last_claimed_slot to the current slot. + pub fn bump_last_claimed_slot(&mut self) -> Result<(), crate::ProgramError> { + self.last_claimed_slot = Clock::get()?.slot; Ok(()) } - pub fn set_last_written_slot(&mut self, slot: u64) { - self.last_written_slot = slot; + /// Explicitly set last_claimed_slot. + pub fn set_last_claimed_slot(&mut self, slot: u64) { + self.last_claimed_slot = slot; } - pub fn last_written_slot(&self) -> u64 { - self.last_written_slot + /// Get last_claimed_slot. + pub fn last_claimed_slot(&self) -> u64 { + self.last_claimed_slot } pub fn set_compressed(&mut self) { @@ -100,12 +136,103 @@ impl CompressionInfo { } } +impl CompressionInfo { + /// Calculate top-up lamports required for a write. + /// + /// Logic (same as CTokens): + /// - If account is compressible (can't pay current + next epoch): return lamports_per_write + deficit + /// - If account has >= max_funded_epochs: return 0 (no top-up needed) + /// - Otherwise: return lamports_per_write (maintenance mode) + pub fn calculate_top_up_lamports( + &self, + num_bytes: u64, + current_slot: u64, + current_lamports: u64, + rent_exemption_lamports: u64, + ) -> u64 { + use light_compressible::rent::AccountRentState; + + let state = AccountRentState { + num_bytes, + current_slot, + current_lamports, + last_claimed_slot: self.last_claimed_slot(), + }; + + // If compressible (emergency mode), return lamports_per_write + deficit + if let Some(rent_deficit) = + state.is_compressible(&self.rent_config, rent_exemption_lamports) + { + return self.lamports_per_write as u64 + rent_deficit; + } + + // Calculate how many epochs we're funded for + let available_balance = state.get_available_rent_balance( + rent_exemption_lamports, + self.rent_config.compression_cost(), + ); + let rent_per_epoch = self.rent_config.rent_curve_per_epoch(num_bytes); + let epochs_funded_ahead = available_balance / rent_per_epoch; + + // If already at or above target, no top-up needed (cruise control) + if epochs_funded_ahead >= self.rent_config.max_funded_epochs as u64 { + return 0; + } + + // Maintenance mode - add lamports_per_write each time + self.lamports_per_write as u64 + } + + /// Top up rent on write if needed and transfer lamports from payer to account. + /// This is the standard pattern for all write operations on compressible PDAs. + /// + /// # Arguments + /// * `account_info` - The PDA account to top up + /// * `payer_info` - The payer account (will be debited) + /// * `system_program_info` - The System Program account for CPI + /// + /// # Returns + /// * `Ok(())` if top-up succeeded or was not needed + /// * `Err(ProgramError)` if transfer failed + pub fn top_up_rent<'a>( + &self, + account_info: &AccountInfo<'a>, + payer_info: &AccountInfo<'a>, + system_program_info: &AccountInfo<'a>, + ) -> Result<(), crate::ProgramError> { + use solana_clock::Clock; + use solana_sysvar::{rent::Rent, Sysvar}; + + let bytes = account_info.data_len() as u64; + let current_lamports = account_info.lamports(); + let current_slot = Clock::get()?.slot; + let rent_exemption_lamports = Rent::get()?.minimum_balance(bytes as usize); + + let top_up = self.calculate_top_up_lamports( + bytes, + current_slot, + current_lamports, + rent_exemption_lamports, + ); + + if top_up > 0 { + // Use System Program CPI to transfer lamports + // This is required because the payer account is owned by the System Program, + // not by the calling program + transfer_lamports_cpi(payer_info, account_info, system_program_info, top_up)?; + } + + Ok(()) + } +} + pub trait Space { const INIT_SPACE: usize; } impl Space for CompressionInfo { - const INIT_SPACE: usize = 8 + 1; // u64 + state enum (u8) + // 2 (u16 config_version) + 4 (u32 lamports_per_write) + 8 (u64 last_claimed_slot) + size_of::() + 1 (CompressionState) + const INIT_SPACE: usize = 2 + 4 + 8 + core::mem::size_of::() + 1; } #[cfg(feature = "anchor")] @@ -113,9 +240,123 @@ impl anchor_lang::Space for CompressionInfo { const INIT_SPACE: usize = ::INIT_SPACE; } +/// Space required for Option when Some (1 byte discriminator + INIT_SPACE). +/// Use this constant in account space calculations. +pub const OPTION_COMPRESSION_INFO_SPACE: usize = 1 + CompressionInfo::INIT_SPACE; + /// Compressed account data used when decompressing. #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] pub struct CompressedAccountData { pub meta: CompressedAccountMetaNoLamportsNoAddress, pub data: T, } + +/// Claim completed-epoch rent to the provided rent sponsor and update last_claimed_slot. +/// Returns Some(claimed) if any lamports were claimed; None if account is compressible or nothing to claim. +pub fn claim_completed_epoch_rent<'info, A>( + account_info: &AccountInfo<'info>, + account_data: &mut A, + rent_sponsor: &AccountInfo<'info>, +) -> Result, ProgramError> +where + A: HasCompressionInfo, +{ + use light_compressible::rent::{AccountRentState, SLOTS_PER_EPOCH}; + use solana_sysvar::rent::Rent; + + let current_slot = Clock::get()?.slot; + let bytes = account_info.data_len() as u64; + let current_lamports = account_info.lamports(); + let rent_exemption_lamports = Rent::get() + .map_err(|_| ProgramError::Custom(0))? + .minimum_balance(bytes as usize); + + let ci = account_data.compression_info_mut(); + let state = AccountRentState { + num_bytes: bytes, + current_slot, + current_lamports, + last_claimed_slot: ci.last_claimed_slot(), + }; + + // If compressible (insufficient for current+next epoch), do not claim + if state + .is_compressible(&ci.rent_config, rent_exemption_lamports) + .is_some() + { + return Ok(None); + } + + // Claim only completed epochs + let claimable = state.calculate_claimable_rent(&ci.rent_config, rent_exemption_lamports); + if let Some(amount) = claimable { + if amount > 0 { + // Advance last_claimed_slot by completed epochs + let completed_epochs = state.get_completed_epochs(); + ci.set_last_claimed_slot( + ci.last_claimed_slot() + .saturating_add(completed_epochs * SLOTS_PER_EPOCH), + ); + + // Transfer lamports to rent sponsor + { + let mut src = account_info + .try_borrow_mut_lamports() + .map_err(|_| ProgramError::Custom(0))?; + let mut dst = rent_sponsor + .try_borrow_mut_lamports() + .map_err(|_| ProgramError::Custom(0))?; + let new_src = src + .checked_sub(amount) + .ok_or(ProgramError::InsufficientFunds)?; + let new_dst = dst.checked_add(amount).ok_or(ProgramError::Custom(0))?; + **src = new_src; + **dst = new_dst; + } + return Ok(Some(amount)); + } + } + Ok(Some(0)) +} + +/// Transfer lamports from one account to another using System Program CPI. +/// This is required when transferring from accounts owned by the System Program. +/// +/// # Arguments +/// * `from` - Source account (owned by System Program) +/// * `to` - Destination account +/// * `system_program` - System Program account +/// * `lamports` - Amount of lamports to transfer +fn transfer_lamports_cpi<'a>( + from: &AccountInfo<'a>, + to: &AccountInfo<'a>, + system_program: &AccountInfo<'a>, + lamports: u64, +) -> Result<(), ProgramError> { + use solana_cpi::invoke; + use solana_instruction::{AccountMeta, Instruction}; + + // System Program ID + const SYSTEM_PROGRAM_ID: [u8; 32] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, + ]; + + // System Program Transfer instruction discriminator: 2 (u32 little-endian) + let mut instruction_data = vec![2, 0, 0, 0]; + instruction_data.extend_from_slice(&lamports.to_le_bytes()); + + let transfer_instruction = Instruction { + program_id: Pubkey::from(SYSTEM_PROGRAM_ID), + accounts: vec![ + AccountMeta::new(*from.key, true), + AccountMeta::new(*to.key, false), + ], + data: instruction_data, + }; + + invoke( + &transfer_instruction, + &[from.clone(), to.clone(), system_program.clone()], + ) +} diff --git a/sdk-libs/sdk/src/compressible/config.rs b/sdk-libs/sdk/src/compressible/config.rs index c5836a8dff..ab1a55c524 100644 --- a/sdk-libs/sdk/src/compressible/config.rs +++ b/sdk-libs/sdk/src/compressible/config.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; +use light_compressible::rent::RentConfig; use solana_account_info::AccountInfo; use solana_cpi::invoke_signed; use solana_loader_v3_interface::state::UpgradeableLoaderState; @@ -21,12 +22,16 @@ const BPF_LOADER_UPGRADEABLE_ID: Pubkey = pub struct CompressibleConfig { /// Config version for future upgrades pub version: u8, - /// Number of slots to wait before compression is allowed - pub compression_delay: u32, + /// Lamports to top up on each write (heuristic) + pub write_top_up: u32, /// Authority that can update the config pub update_authority: Pubkey, /// Account that receives rent from compressed PDAs pub rent_sponsor: Pubkey, + /// Authority that can compress/close PDAs (distinct from rent_sponsor) + pub compression_authority: Pubkey, + /// Rent function parameters for compressibility and distribution + pub rent_config: RentConfig, /// Config bump seed (0) pub config_bump: u8, /// PDA bump seed @@ -36,17 +41,39 @@ pub struct CompressibleConfig { } impl CompressibleConfig { - pub const LEN: usize = 1 + 4 + 32 + 32 + 1 + 4 + (32 * MAX_ADDRESS_TREES_PER_SPACE) + 1; // 107 bytes max + pub const LEN: usize = 1 + + 4 + + 32 + + 32 + + 32 + + core::mem::size_of::() + + 1 + + 1 + + 4 + + (32 * MAX_ADDRESS_TREES_PER_SPACE); /// Calculate the exact size needed for a CompressibleConfig with the given /// number of address spaces pub fn size_for_address_space(num_address_trees: usize) -> usize { - 1 + 4 + 32 + 32 + 1 + 4 + (32 * num_address_trees) + 1 + 1 + 4 + + 32 + + 32 + + 32 + + core::mem::size_of::() + + 1 + + 1 + + 4 + + (32 * num_address_trees) } /// Derives the config PDA address with config bump pub fn derive_pda(program_id: &Pubkey, config_bump: u8) -> (Pubkey, u8) { - Pubkey::find_program_address(&[COMPRESSIBLE_CONFIG_SEED, &[config_bump]], program_id) + // Convert u8 to u16 to match program-libs derivation (uses u16 with to_le_bytes) + let config_bump_u16 = config_bump as u16; + Pubkey::find_program_address( + &[COMPRESSIBLE_CONFIG_SEED, &config_bump_u16.to_le_bytes()], + program_id, + ) } /// Derives the default config PDA address (config_bump = 0) @@ -131,8 +158,10 @@ impl CompressibleConfig { /// * `config_account` - The config PDA account to initialize /// * `update_authority` - Authority that can update the config after creation /// * `rent_sponsor` - Account that receives rent from compressed PDAs +/// * `compression_authority` - Authority that can compress/close PDAs +/// * `rent_config` - Rent function parameters +/// * `write_top_up` - Lamports to top up on each write /// * `address_space` - Address space for compressed accounts (currently 1 address_tree allowed) -/// * `compression_delay` - Number of slots to wait before compression /// * `config_bump` - Config bump seed (must be 0 for now) /// * `payer` - Account paying for the PDA creation /// * `system_program` - System program @@ -151,8 +180,10 @@ pub fn process_initialize_compression_config_account_info<'info>( config_account: &AccountInfo<'info>, update_authority: &AccountInfo<'info>, rent_sponsor: &Pubkey, + compression_authority: &Pubkey, + rent_config: RentConfig, + write_top_up: u32, address_space: Vec, - compression_delay: u32, config_bump: u8, payer: &AccountInfo<'info>, system_program: &AccountInfo<'info>, @@ -199,7 +230,13 @@ pub fn process_initialize_compression_config_account_info<'info>( let account_size = CompressibleConfig::size_for_address_space(address_space.len()); let rent_lamports = rent.minimum_balance(account_size); - let seeds = &[COMPRESSIBLE_CONFIG_SEED, &[config_bump], &[bump]]; + // Use u16 to_le_bytes to match derive_pda (2 bytes instead of 1) + let config_bump_bytes = (config_bump as u16).to_le_bytes(); + let seeds = &[ + COMPRESSIBLE_CONFIG_SEED, + config_bump_bytes.as_ref(), + &[bump], + ]; let create_account_ix = system_instruction::create_account( payer.key, config_account.key, @@ -221,9 +258,11 @@ pub fn process_initialize_compression_config_account_info<'info>( let config = CompressibleConfig { version: 1, - compression_delay, + write_top_up, update_authority: *update_authority.key, rent_sponsor: *rent_sponsor, + compression_authority: *compression_authority, + rent_config, config_bump, address_space, bump, @@ -246,20 +285,25 @@ pub fn process_initialize_compression_config_account_info<'info>( /// * `authority` - Current update authority (must match config) /// * `new_update_authority` - Optional new update authority /// * `new_rent_sponsor` - Optional new rent recipient +/// * `new_compression_authority` - Optional new compression authority +/// * `new_rent_config` - Optional new rent function parameters +/// * `new_write_top_up` - Optional new write top-up amount /// * `new_address_space` - Optional new address space (currently 1 address_tree allowed) -/// * `new_compression_delay` - Optional new compression delay /// * `owner_program_id` - The program that owns the config /// /// # Returns /// * `Ok(())` if config was updated successfully /// * `Err(ProgramError)` if there was an error +#[allow(clippy::too_many_arguments)] pub fn process_update_compression_config<'info>( config_account: &AccountInfo<'info>, authority: &AccountInfo<'info>, new_update_authority: Option<&Pubkey>, new_rent_sponsor: Option<&Pubkey>, + new_compression_authority: Option<&Pubkey>, + new_rent_config: Option, + new_write_top_up: Option, new_address_space: Option>, - new_compression_delay: Option, owner_program_id: &Pubkey, ) -> Result<(), crate::ProgramError> { // CHECK: PDA derivation @@ -282,6 +326,15 @@ pub fn process_update_compression_config<'info>( if let Some(new_recipient) = new_rent_sponsor { config.rent_sponsor = *new_recipient; } + if let Some(new_auth) = new_compression_authority { + config.compression_authority = *new_auth; + } + if let Some(new_rcfg) = new_rent_config { + config.rent_config = new_rcfg; + } + if let Some(new_top_up) = new_write_top_up { + config.write_top_up = new_top_up; + } if let Some(new_address_space) = new_address_space { // CHECK: address space length if new_address_space.len() != MAX_ADDRESS_TREES_PER_SPACE { @@ -298,9 +351,6 @@ pub fn process_update_compression_config<'info>( config.address_space = new_address_space; } - if let Some(new_delay) = new_compression_delay { - config.compression_delay = new_delay; - } let mut data = config_account.try_borrow_mut_data().map_err(|e| { msg!("Failed to borrow mut data for config_account: {:?}", e); @@ -396,9 +446,11 @@ pub fn check_program_upgrade_authority( /// * `update_authority` - Must be the program's upgrade authority /// * `program_data_account` - The program's data account for validation /// * `rent_sponsor` - Account that receives rent from compressed PDAs +/// * `compression_authority` - Authority that can compress/close PDAs +/// * `rent_config` - Rent function parameters +/// * `write_top_up` - Lamports to top up on each write /// * `address_space` - Address spaces for compressed accounts (exactly 1 /// allowed) -/// * `compression_delay` - Number of slots to wait before compression /// * `config_bump` - Config bump seed (must be 0 for now) /// * `payer` - Account paying for the PDA creation /// * `system_program` - System program @@ -413,8 +465,10 @@ pub fn process_initialize_compression_config_checked<'info>( update_authority: &AccountInfo<'info>, program_data_account: &AccountInfo<'info>, rent_sponsor: &Pubkey, + compression_authority: &Pubkey, + rent_config: RentConfig, + write_top_up: u32, address_space: Vec, - compression_delay: u32, config_bump: u8, payer: &AccountInfo<'info>, system_program: &AccountInfo<'info>, @@ -436,8 +490,10 @@ pub fn process_initialize_compression_config_checked<'info>( config_account, update_authority, rent_sponsor, + compression_authority, + rent_config, + write_top_up, address_space, - compression_delay, config_bump, payer, system_program, diff --git a/sdk-libs/sdk/src/compressible/decompress_idempotent.rs b/sdk-libs/sdk/src/compressible/decompress_idempotent.rs index 42ef34640d..317341fa70 100644 --- a/sdk-libs/sdk/src/compressible/decompress_idempotent.rs +++ b/sdk-libs/sdk/src/compressible/decompress_idempotent.rs @@ -12,9 +12,11 @@ use solana_system_interface::instruction as system_instruction; use solana_sysvar::{rent::Rent, Sysvar}; use crate::{ - account::sha::LightAccount, compressible::compression_info::HasCompressionInfo, - cpi::v2::CpiAccounts, error::LightSdkError, AnchorDeserialize, AnchorSerialize, - LightDiscriminator, + account::sha::LightAccount, + compressible::compression_info::{CompressionInfo, HasCompressionInfo, Space}, + cpi::v2::CpiAccounts, + error::LightSdkError, + AnchorDeserialize, AnchorSerialize, LightDiscriminator, }; /// Convert a `CompressedAccountMetaNoLamportsNoAddress` to a @@ -45,7 +47,7 @@ pub fn into_compressed_meta_with_address<'info>( /// Helper to invoke create_account on heap. #[inline(never)] fn invoke_create_account_with_heap<'info>( - rent_payer: &AccountInfo<'info>, + rent_sponsor: &AccountInfo<'info>, solana_account: &AccountInfo<'info>, rent_minimum_balance: u64, space: u64, @@ -54,7 +56,7 @@ fn invoke_create_account_with_heap<'info>( system_program: &AccountInfo<'info>, ) -> Result<(), LightSdkError> { let create_account_ix = system_instruction::create_account( - rent_payer.key, + rent_sponsor.key, solana_account.key, rent_minimum_balance, space, @@ -64,7 +66,7 @@ fn invoke_create_account_with_heap<'info>( invoke_signed( &create_account_ix, &[ - rent_payer.clone(), + rent_sponsor.clone(), solana_account.clone(), system_program.clone(), ], @@ -82,7 +84,7 @@ pub fn prepare_account_for_decompression_idempotent<'a, 'info, T>( data: T, compressed_meta: CompressedAccountMeta, solana_account: &AccountInfo<'info>, - rent_payer: &AccountInfo<'info>, + rent_sponsor: &AccountInfo<'info>, cpi_accounts: &CpiAccounts<'a, 'info>, signer_seeds: &[&[u8]], ) -> Result< @@ -110,11 +112,17 @@ where let light_account = LightAccount::::new_close(program_id, &compressed_meta, data)?; - let space = T::size(&light_account.account); + // Account space needs to include discriminator + serialized data + // The compressed account has compression_info: None, but after decompression + // it will have compression_info: Some(...), so we need to add that space + let discriminator_len = T::LIGHT_DISCRIMINATOR.len(); + let base_space = discriminator_len + T::size(&light_account.account); + // Add space for CompressionInfo (Option::None is 1 byte, Option::Some is 1 + INIT_SPACE) + let space = base_space + CompressionInfo::INIT_SPACE; let rent_minimum_balance = rent.minimum_balance(space); invoke_create_account_with_heap( - rent_payer, + rent_sponsor, solana_account, rent_minimum_balance, space as u64, @@ -124,8 +132,7 @@ where )?; let mut decompressed_pda = light_account.account.clone(); - *decompressed_pda.compression_info_mut_opt() = - Some(super::compression_info::CompressionInfo::new_decompressed()?); + *decompressed_pda.compression_info_mut_opt() = Some(CompressionInfo::new_decompressed()?); let mut account_data = solana_account.try_borrow_mut_data()?; let discriminator_len = T::LIGHT_DISCRIMINATOR.len(); diff --git a/sdk-libs/sdk/src/compressible/decompress_runtime.rs b/sdk-libs/sdk/src/compressible/decompress_runtime.rs new file mode 100644 index 0000000000..dbf353119e --- /dev/null +++ b/sdk-libs/sdk/src/compressible/decompress_runtime.rs @@ -0,0 +1,348 @@ +//! Traits and processor for decompress_accounts_idempotent instruction. +use light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo; +#[cfg(feature = "cpi-context")] +use light_sdk_types::cpi_context_write::CpiContextWriteAccounts; +use light_sdk_types::{ + cpi_accounts::CpiAccountsConfig, + instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, CpiSigner, +}; +use solana_account_info::AccountInfo; +use solana_msg::msg; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +use crate::{ + cpi::{ + v2::{CpiAccounts, LightSystemProgramCpi}, + InvokeLightSystemProgram, LightCpiInstruction, + }, + AnchorDeserialize, AnchorSerialize, LightDiscriminator, +}; + +/// Trait for account variants that can be checked for token vs PDA type. +pub trait HasTokenVariant { + /// Returns true if this variant represents a token account (PackedCTokenData). + fn is_packed_ctoken(&self) -> bool; +} + +/// Trait for CToken seed providers. +/// +/// Also defined in compressed-token-sdk for token-specific runtime helpers. +pub trait CTokenSeedProvider: Copy { + /// Type of accounts struct needed for seed derivation. + type Accounts<'info>; + + /// Get seeds for the token account PDA (used for decompression). + fn get_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [AccountInfo<'info>], + ) -> Result<(Vec>, Pubkey), ProgramError>; + + /// Get authority seeds for signing during compression. + fn get_authority_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + remaining_accounts: &'a [AccountInfo<'info>], + ) -> Result<(Vec>, Pubkey), ProgramError>; +} + +/// Context trait for decompression. +pub trait DecompressContext<'info> { + /// The compressed account data type (wraps program's variant enum) + type CompressedData: HasTokenVariant; + + /// Packed token data type + type PackedTokenData; + + /// Compressed account metadata type (standardized) + type CompressedMeta: Clone; + + /// Seed parameters type containing data.* field values from instruction data + type SeedParams; + + // Account accessors + fn fee_payer(&self) -> &AccountInfo<'info>; + fn config(&self) -> &AccountInfo<'info>; + fn rent_sponsor(&self) -> &AccountInfo<'info>; + fn ctoken_rent_sponsor(&self) -> Option<&AccountInfo<'info>>; + fn ctoken_program(&self) -> Option<&AccountInfo<'info>>; + fn ctoken_cpi_authority(&self) -> Option<&AccountInfo<'info>>; + fn ctoken_config(&self) -> Option<&AccountInfo<'info>>; + + /// Collect and unpack compressed accounts into PDAs and tokens. + /// + /// Caller program-specific: handles variant matching and PDA seed derivation. + #[allow(clippy::type_complexity)] + #[allow(clippy::too_many_arguments)] + fn collect_pda_and_token<'b>( + &self, + cpi_accounts: &CpiAccounts<'b, 'info>, + address_space: Pubkey, + compressed_accounts: Vec, + solana_accounts: &[AccountInfo<'info>], + seed_params: Option<&Self::SeedParams>, + ) -> Result<( + Vec, + Vec<(Self::PackedTokenData, Self::CompressedMeta)> + ), ProgramError>; + + /// Process token decompression. + /// + /// Caller program-specific: handles token account creation and seed derivation. + #[allow(clippy::too_many_arguments)] + fn process_tokens<'b>( + &self, + remaining_accounts: &[AccountInfo<'info>], + fee_payer: &AccountInfo<'info>, + ctoken_program: &AccountInfo<'info>, + ctoken_rent_sponsor: &AccountInfo<'info>, + ctoken_cpi_authority: &AccountInfo<'info>, + ctoken_config: &AccountInfo<'info>, + config: &AccountInfo<'info>, + ctoken_accounts: Vec<(Self::PackedTokenData, Self::CompressedMeta)>, + proof: crate::instruction::ValidityProof, + cpi_accounts: &CpiAccounts<'b, 'info>, + post_system_accounts: &[AccountInfo<'info>], + has_pdas: bool, + ) -> Result<(), ProgramError>; +} + +/// Trait for PDA types that can derive seeds with full account context access. +/// +/// - A: The accounts struct type (typically DecompressAccountsIdempotent<'info>) +/// - S: The SeedParams struct containing data.* field values from instruction data +/// +/// This allows PDA seeds to reference: +/// - `data.*` fields from instruction parameters (seed_params.field) +/// - `ctx.*` accounts from the instruction context (accounts.field) +/// +/// For off-chain PDA derivation, use the generated client helper functions (get_*_seeds). +pub trait PdaSeedDerivation { + fn derive_pda_seeds_with_accounts( + &self, + program_id: &Pubkey, + accounts: &A, + seed_params: &S, + ) -> (Vec>, Pubkey); +} + +/// Check compressed accounts to determine if we have tokens and/or PDAs. +#[inline(never)] +pub fn check_account_types(compressed_accounts: &[T]) -> (bool, bool) { + let (mut has_tokens, mut has_pdas) = (false, false); + for account in compressed_accounts { + if account.is_packed_ctoken() { + has_tokens = true; + } else { + has_pdas = true; + } + if has_tokens && has_pdas { + break; + } + } + (has_tokens, has_pdas) +} + +/// Handler for unpacking and preparing a single PDA variant for decompression. +#[inline(never)] +#[allow(clippy::too_many_arguments)] +pub fn handle_packed_pda_variant<'a, 'b, 'info, T, P, A, S>( + accounts_rent_sponsor: &AccountInfo<'info>, + cpi_accounts: &CpiAccounts<'b, 'info>, + address_space: Pubkey, + solana_account: &AccountInfo<'info>, + index: usize, + packed: &P, + meta: &CompressedAccountMetaNoLamportsNoAddress, + post_system_accounts: &[AccountInfo<'info>], + compressed_pda_infos: &mut Vec, + program_id: &Pubkey, + seed_accounts: &A, + seed_params: Option<&S>, +) -> Result<(), ProgramError> +where + T: PdaSeedDerivation + + Clone + + crate::account::Size + + LightDiscriminator + + Default + + AnchorSerialize + + AnchorDeserialize + + crate::compressible::HasCompressionInfo + + 'info, + P: crate::compressible::Unpack, + S: Default, +{ + let data: T = P::unpack(packed, post_system_accounts)?; + + // CHECK: pda match + // Call the method with account context and seed params + let (seeds_vec, derived_pda) = if let Some(params) = seed_params { + data.derive_pda_seeds_with_accounts(program_id, seed_accounts, params) + } else { + // For implementations without seed params, create a default one + let default_params = S::default(); + data.derive_pda_seeds_with_accounts(program_id, seed_accounts, &default_params) + }; + if derived_pda != *solana_account.key { + msg!( + "Derived PDA does not match account at index {}: expected {:?}, got {:?}, seeds: {:?}", + index, + solana_account.key, + derived_pda, + seeds_vec + ); + } + + // prepare decompression + let compressed_infos = { + let seed_refs: Vec<&[u8]> = seeds_vec.iter().map(|v| v.as_slice()).collect(); + crate::compressible::decompress_idempotent::prepare_account_for_decompression_idempotent::( + program_id, + data, + crate::compressible::decompress_idempotent::into_compressed_meta_with_address( + meta, + solana_account, + address_space, + program_id, + ), + solana_account, + accounts_rent_sponsor, + cpi_accounts, + seed_refs.as_slice(), + )? + }; + compressed_pda_infos.extend(compressed_infos); + Ok(()) +} + +/// Processor for decompress_accounts_idempotent. +#[inline(never)] +#[allow(clippy::too_many_arguments)] +pub fn process_decompress_accounts_idempotent<'info, Ctx>( + ctx: &Ctx, + remaining_accounts: &[AccountInfo<'info>], + compressed_accounts: Vec, + proof: crate::instruction::ValidityProof, + system_accounts_offset: u8, + cpi_signer: CpiSigner, + program_id: &Pubkey, + seed_params: Option<&Ctx::SeedParams>, +) -> Result<(), ProgramError> +where + Ctx: DecompressContext<'info>, +{ + let compression_config = + crate::compressible::CompressibleConfig::load_checked(ctx.config(), program_id)?; + let address_space = compression_config.address_space[0]; + + let (has_tokens, has_pdas) = check_account_types(&compressed_accounts); + if !has_tokens && !has_pdas { + return Ok(()); + } + + let cpi_accounts = if has_tokens { + CpiAccounts::new_with_config( + ctx.fee_payer(), + &remaining_accounts[system_accounts_offset as usize..], + CpiAccountsConfig::new_with_cpi_context(cpi_signer), + ) + } else { + CpiAccounts::new( + ctx.fee_payer(), + &remaining_accounts[system_accounts_offset as usize..], + cpi_signer, + ) + }; + + let pda_accounts_start = remaining_accounts.len() - compressed_accounts.len(); + let solana_accounts = &remaining_accounts[pda_accounts_start..]; + let post_system_offset = cpi_accounts.system_accounts_end_offset(); + let all_infos = cpi_accounts.account_infos(); + let post_system_accounts = &all_infos[post_system_offset..]; + + // Call trait method for program-specific collection + let (compressed_pda_infos, compressed_token_accounts) = ctx.collect_pda_and_token( + &cpi_accounts, + address_space, + compressed_accounts, + solana_accounts, + seed_params, + )?; + + let has_pdas = !compressed_pda_infos.is_empty(); + let has_tokens = !compressed_token_accounts.is_empty(); + if !has_pdas && !has_tokens { + return Ok(()); + } + + let fee_payer = ctx.fee_payer(); + + // Decompress PDAs via LightSystemProgram + #[cfg(feature = "cpi-context")] + if has_pdas && has_tokens { + let authority = cpi_accounts + .authority() + .map_err(|_| ProgramError::MissingRequiredSignature)?; + let cpi_context = cpi_accounts + .cpi_context() + .map_err(|_| ProgramError::MissingRequiredSignature)?; + let system_cpi_accounts = CpiContextWriteAccounts { + fee_payer, + authority, + cpi_context, + cpi_signer, + }; + + LightSystemProgramCpi::new_cpi(cpi_signer, proof) + .with_account_infos(&compressed_pda_infos) + .write_to_cpi_context_first() + .invoke_write_to_cpi_context_first(system_cpi_accounts)?; + } else if has_pdas { + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_account_infos(&compressed_pda_infos) + .invoke(cpi_accounts.clone())?; + } + + // TODO: fix this + #[cfg(not(feature = "cpi-context"))] + if has_pdas { + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_account_infos(&compressed_pda_infos) + .invoke(cpi_accounts.clone())?; + } + + // Decompress tokens via trait method + if has_tokens { + let ctoken_program = ctx + .ctoken_program() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let ctoken_rent_sponsor = ctx + .ctoken_rent_sponsor() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let ctoken_cpi_authority = ctx + .ctoken_cpi_authority() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let ctoken_config = ctx + .ctoken_config() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + + ctx.process_tokens( + remaining_accounts, + fee_payer, + ctoken_program, + ctoken_rent_sponsor, + ctoken_cpi_authority, + ctoken_config, + ctx.config(), + compressed_token_accounts, + proof, + &cpi_accounts, + post_system_accounts, + has_pdas, + )?; + } + + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/mod.rs b/sdk-libs/sdk/src/compressible/mod.rs index 7c72acbb9f..6942dd4412 100644 --- a/sdk-libs/sdk/src/compressible/mod.rs +++ b/sdk-libs/sdk/src/compressible/mod.rs @@ -7,15 +7,22 @@ pub mod compress_account; #[cfg(feature = "v2")] pub mod compress_account_on_init; #[cfg(feature = "v2")] +pub mod compress_runtime; +#[cfg(feature = "v2")] pub mod decompress_idempotent; #[cfg(feature = "v2")] +pub mod decompress_runtime; +#[cfg(feature = "v2")] pub use close::close; #[cfg(feature = "v2")] pub use compress_account::prepare_account_for_compression; #[cfg(feature = "v2")] pub use compress_account_on_init::prepare_compressed_account_on_init; +#[cfg(feature = "v2")] +pub use compress_runtime::{process_compress_pda_accounts_idempotent, CompressContext}; pub use compression_info::{ CompressAs, CompressedInitSpace, CompressionInfo, HasCompressionInfo, Pack, Space, Unpack, + OPTION_COMPRESSION_INFO_SPACE, }; pub use config::{ process_initialize_compression_config_account_info, @@ -26,3 +33,8 @@ pub use config::{ pub use decompress_idempotent::{ into_compressed_meta_with_address, prepare_account_for_decompression_idempotent, }; +#[cfg(feature = "v2")] +pub use decompress_runtime::{ + check_account_types, handle_packed_pda_variant, process_decompress_accounts_idempotent, + CTokenSeedProvider, DecompressContext, HasTokenVariant, PdaSeedDerivation, +}; diff --git a/sdk-libs/sdk/src/lib.rs b/sdk-libs/sdk/src/lib.rs index 87606a1dda..2cbff5d451 100644 --- a/sdk-libs/sdk/src/lib.rs +++ b/sdk-libs/sdk/src/lib.rs @@ -173,7 +173,8 @@ pub use light_hasher; use light_hasher::DataHasher; pub use light_macros::{derive_light_cpi_signer, derive_light_cpi_signer_pda}; pub use light_sdk_macros::{ - light_system_accounts, LightDiscriminator, LightHasher, LightHasherSha, LightTraits, + derive_light_rent_sponsor, derive_light_rent_sponsor_pda, light_system_accounts, + LightDiscriminator, LightHasher, LightHasherSha, LightTraits, }; pub use light_sdk_types::{constants, CpiSigner}; use solana_account_info::AccountInfo; diff --git a/sdk-tests/client-test/Cargo.toml b/sdk-tests/client-test/Cargo.toml index 86c9cfaa1d..8412d9325f 100644 --- a/sdk-tests/client-test/Cargo.toml +++ b/sdk-tests/client-test/Cargo.toml @@ -16,7 +16,7 @@ name = "client_test" [dev-dependencies] light-client = { workspace = true, features = ["devenv"] } -light-program-test = { workspace = true, features = ["devenv", "v2"] } +light-program-test = { workspace = true, features = ["devenv"] } light-prover-client = { workspace = true, features = ["devenv"] } light-test-utils = { workspace = true } light-sdk = { workspace = true } diff --git a/sdk-tests/csdk-anchor-derived-test/Anchor.toml b/sdk-tests/csdk-anchor-derived-test/Anchor.toml new file mode 100644 index 0000000000..3237e0c97f --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/Anchor.toml @@ -0,0 +1,18 @@ +[features] +resolution = true +skip-lint = false + +[programs.localnet] +csdk_anchor_derived_test = "FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah" + +[registry] +url = "https://api.apr.dev" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test-sbf -p csdk-anchor-derived-test -- --nocapture" + + diff --git a/sdk-tests/csdk-anchor-derived-test/Cargo.toml b/sdk-tests/csdk-anchor-derived-test/Cargo.toml new file mode 100644 index 0000000000..7f0da9d543 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "csdk-anchor-derived-test" +version = "0.1.0" +description = "Anchor program test using add_compressible_instructions-derived instructions" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "csdk_anchor_derived_test" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build"] +test-sbf = [] + +[dependencies] +light-sdk = { workspace = true, features = ["anchor", "idl-build", "v2", "anchor-discriminator", "cpi-context"] } +light-sdk-types = { workspace = true, features = ["v2", "cpi-context"] } +light-hasher = { workspace = true, features = ["solana"] } +solana-program = { workspace = true } +solana-pubkey = { workspace = true } +light-macros = { workspace = true, features = ["solana"] } +light-sdk-macros = { workspace = true } +borsh = { workspace = true } +light-compressed-account = { workspace = true, features = ["solana"] } +anchor-lang = { workspace = true, features = ["idl-build"] } +anchor-spl = { version = "=0.31.1", git = "https://github.com/lightprotocol/anchor", rev = "d8a2b3d9", features = ["memo", "metadata", "idl-build"] } +light-ctoken-types = { workspace = true, features = ["anchor"] } +light-compressed-token-sdk = { workspace = true, features = ["anchor"] } +light-compressed-token-types = { workspace = true, features = ["anchor"] } +light-compressible = { workspace = true, features = ["anchor"] } + +[dev-dependencies] +light-token-client = { workspace = true } +light-program-test = { workspace = true, features = ["devenv"] } +light-client = { workspace = true, features = ["v2"] } +light-compressible-client = { workspace = true, features = ["anchor"] } +light-test-utils = { workspace = true } +tokio = { workspace = true } +solana-sdk = { workspace = true } +solana-logger = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +solana-signature = { workspace = true } +solana-signer = { workspace = true } +solana-keypair = { workspace = true } +solana-account = { workspace = true } +bincode = "1.3" + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] + + diff --git a/sdk-tests/csdk-anchor-derived-test/Xargo.toml b/sdk-tests/csdk-anchor-derived-test/Xargo.toml new file mode 100644 index 0000000000..4f10b17d74 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/Xargo.toml @@ -0,0 +1,4 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] + + diff --git a/sdk-tests/csdk-anchor-derived-test/package.json b/sdk-tests/csdk-anchor-derived-test/package.json new file mode 100644 index 0000000000..9f27c61933 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/package.json @@ -0,0 +1,11 @@ +{ + "name": "@lightprotocol/csdk-anchor-derived-test", + "version": "0.1.0", + "license": "Apache-2.0", + "scripts": { + "build": "cargo build-sbf", + "test": "cargo test-sbf -p csdk-anchor-derived-test -- --nocapture" + }, + "nx": {} +} + diff --git a/sdk-tests/csdk-anchor-derived-test/src/errors.rs b/sdk-tests/csdk-anchor-derived-test/src/errors.rs new file mode 100644 index 0000000000..e7bdc66a08 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/src/errors.rs @@ -0,0 +1,12 @@ +use anchor_lang::prelude::ProgramError; + +#[repr(u32)] +pub enum ErrorCode { + RentRecipientMismatch, +} + +impl From for ProgramError { + fn from(e: ErrorCode) -> Self { + ProgramError::Custom(e as u32) + } +} diff --git a/sdk-tests/csdk-anchor-derived-test/src/instruction_accounts.rs b/sdk-tests/csdk-anchor-derived-test/src/instruction_accounts.rs new file mode 100644 index 0000000000..13e0100ddd --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/src/instruction_accounts.rs @@ -0,0 +1,109 @@ +use anchor_lang::prelude::*; + +use crate::state::*; + +#[derive(Accounts)] +#[instruction(account_data: AccountCreationData)] +pub struct CreateUserRecordAndGameSession<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + init, + payer = user, + space = 8 + UserRecord::INIT_SPACE, + seeds = [b"user_record", user.key().as_ref()], + bump, + )] + pub user_record: Account<'info, UserRecord>, + #[account( + init, + payer = user, + space = 8 + GameSession::INIT_SPACE, + seeds = [b"game_session", account_data.session_id.to_le_bytes().as_ref()], + bump, + )] + pub game_session: Account<'info, GameSession>, + + /// The mint signer used for PDA derivation + pub mint_signer: Signer<'info>, + + /// The mint authority used for PDA derivation + pub mint_authority: Signer<'info>, + + /// Compressed token program + /// CHECK: Program ID validated using C_TOKEN_PROGRAM_ID constant + pub ctoken_program: UncheckedAccount<'info>, + + /// CHECK: CPI authority of the compressed token program + pub compress_token_program_cpi_authority: UncheckedAccount<'info>, + + /// Needs to be here for the init anchor macro to work. + pub system_program: Program<'info, System>, + + /// Global compressible config + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_sponsor: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct InitializeCompressionConfig<'info> { + #[account(mut)] + pub payer: Signer<'info>, + /// CHECK: Config PDA is created and validated by the SDK + #[account(mut)] + pub config: AccountInfo<'info>, + /// CHECK: Program data account is validated by the SDK + pub program_data: AccountInfo<'info>, + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct UpdateCompressionConfig<'info> { + /// CHECK: Checked by SDK + #[account(mut)] + pub config: AccountInfo<'info>, + pub authority: Signer<'info>, +} + +#[derive(Accounts)] +pub struct DecompressAccountsIdempotent<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + /// CHECK: checked by SDK + pub config: AccountInfo<'info>, + #[account(mut)] + pub rent_sponsor: AccountInfo<'info>, + /// CHECK: anyone can pay (optional - only needed if decompressing tokens) + #[account(mut)] + pub ctoken_rent_sponsor: Option>, + /// CHECK: checked by SDK (optional - only needed if decompressing tokens) + pub ctoken_config: Option>, + /// CHECK: + #[account(address = solana_pubkey::pubkey!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"))] + pub ctoken_program: Option>, + /// CHECK: + #[account(address = solana_pubkey::pubkey!("GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy"))] + pub ctoken_cpi_authority: Option>, + /// CHECK: checked by SDK + pub some_mint: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct CompressAccountsIdempotent<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + /// CHECK: Checked by SDK + pub config: AccountInfo<'info>, + /// CHECK: checked by SDK + #[account(mut)] + pub rent_sponsor: AccountInfo<'info>, + /// CHECK: Checked by SDK + #[account(mut)] + pub compression_authority: AccountInfo<'info>, +} diff --git a/sdk-tests/csdk-anchor-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-derived-test/src/lib.rs new file mode 100644 index 0000000000..7d15d0a8a0 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/src/lib.rs @@ -0,0 +1,283 @@ +#![allow(deprecated)] + +use anchor_lang::prelude::*; +use light_sdk::derive_light_cpi_signer; +use light_sdk_types::CpiSigner; + +pub mod errors; +pub mod instruction_accounts; +pub mod processor; +pub mod seeds; +pub mod state; +pub mod variant; + +pub use instruction_accounts::*; +pub use state::{ + AccountCreationData, CompressionParams, GameSession, PlaceholderRecord, UserRecord, +}; +pub use variant::{CTokenAccountVariant, CompressedAccountData, CompressedAccountVariant}; + +declare_id!("FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah"); + +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah"); +#[program] +pub mod csdk_anchor_derived_test { + use anchor_lang::solana_program::{program::invoke, sysvar::clock::Clock}; + use light_compressed_token_sdk::instructions::{ + create_mint_action_cpi, find_spl_mint_address, MintActionInputs, + }; + use light_sdk::{ + compressible::{ + compress_account_on_init::prepare_compressed_account_on_init, CompressibleConfig, + }, + cpi::{ + v2::{CpiAccounts, LightSystemProgramCpi}, + InvokeLightSystemProgram, LightCpiInstruction, + }, + }; + use light_sdk_types::{ + cpi_accounts::CpiAccountsConfig, cpi_context_write::CpiContextWriteAccounts, + }; + + use super::*; + use crate::{ + errors::ErrorCode, + seeds::get_ctoken_signer_seeds, + state::{GameSession, UserRecord}, + LIGHT_CPI_SIGNER, + }; + + pub fn create_user_record_and_game_session<'info>( + ctx: Context<'_, '_, '_, 'info, CreateUserRecordAndGameSession<'info>>, + account_data: AccountCreationData, + compression_params: CompressionParams, + ) -> Result<()> { + let user_record = &mut ctx.accounts.user_record; + let game_session = &mut ctx.accounts.game_session; + + let config = CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; + + if ctx.accounts.rent_sponsor.key() != config.rent_sponsor { + return Err(ProgramError::from(ErrorCode::RentRecipientMismatch).into()); + } + + user_record.owner = ctx.accounts.user.key(); + user_record.name = account_data.user_name.clone(); + user_record.score = 11; + + game_session.session_id = account_data.session_id; + game_session.player = ctx.accounts.user.key(); + game_session.game_type = account_data.game_type.clone(); + game_session.start_time = Clock::get()?.unix_timestamp as u64; + game_session.end_time = None; + game_session.score = 0; + + let cpi_accounts = CpiAccounts::new_with_config( + ctx.accounts.user.as_ref(), + ctx.remaining_accounts, + CpiAccountsConfig::new_with_cpi_context(LIGHT_CPI_SIGNER), + ); + let cpi_context_pubkey = cpi_accounts.cpi_context().unwrap().key(); + let cpi_context_account = cpi_accounts.cpi_context().unwrap(); + + let user_new_address_params = compression_params + .user_address_tree_info + .into_new_address_params_assigned_packed(user_record.key().to_bytes().into(), Some(0)); + let game_new_address_params = compression_params + .game_address_tree_info + .into_new_address_params_assigned_packed(game_session.key().to_bytes().into(), Some(1)); + + let mut all_compressed_infos = Vec::new(); + + let user_record_info = user_record.to_account_info(); + let user_record_data_mut = &mut **user_record; + let user_compressed_info = prepare_compressed_account_on_init::( + &user_record_info, + user_record_data_mut, + &config, + compression_params.user_compressed_address, + user_new_address_params, + compression_params.user_output_state_tree_index, + &cpi_accounts, + &config.address_space, + true, + )?; + all_compressed_infos.push(user_compressed_info); + + let game_session_info = game_session.to_account_info(); + let game_session_data_mut = &mut **game_session; + let game_compressed_info = prepare_compressed_account_on_init::( + &game_session_info, + game_session_data_mut, + &config, + compression_params.game_compressed_address, + game_new_address_params, + compression_params.game_output_state_tree_index, + &cpi_accounts, + &config.address_space, + true, + )?; + all_compressed_infos.push(game_compressed_info); + + let cpi_context_accounts = CpiContextWriteAccounts { + fee_payer: cpi_accounts.fee_payer(), + authority: cpi_accounts.authority().unwrap(), + cpi_context: cpi_context_account, + cpi_signer: LIGHT_CPI_SIGNER, + }; + LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, compression_params.proof) + .with_new_addresses(&[user_new_address_params, game_new_address_params]) + .with_account_infos(&all_compressed_infos) + .write_to_cpi_context_first() + .invoke_write_to_cpi_context_first(cpi_context_accounts)?; + + let mint = find_spl_mint_address(&ctx.accounts.mint_signer.key()).0; + let (_, token_account_address) = get_ctoken_signer_seeds(&ctx.accounts.user.key(), &mint); + + let actions = vec![ + light_compressed_token_sdk::instructions::mint_action::MintActionType::MintTo { + recipients: vec![ + light_compressed_token_sdk::instructions::mint_action::MintToRecipient { + recipient: token_account_address, + amount: 1000, + }, + ], + token_account_version: 3, + }, + ]; + + let output_queue = *cpi_accounts.tree_accounts().unwrap()[0].key; + let address_tree_pubkey = *cpi_accounts.tree_accounts().unwrap()[1].key; + + let mint_action_inputs = MintActionInputs { + compressed_mint_inputs: compression_params.mint_with_context.clone(), + mint_seed: ctx.accounts.mint_signer.key(), + mint_bump: Some(compression_params.mint_bump), + create_mint: true, + authority: ctx.accounts.mint_authority.key(), + payer: ctx.accounts.user.key(), + proof: compression_params.proof.into(), + actions, + input_queue: None, + output_queue, + tokens_out_queue: Some(output_queue), + address_tree_pubkey, + token_pool: None, + }; + + let mint_action_instruction = create_mint_action_cpi( + mint_action_inputs, + Some(light_ctoken_types::instructions::mint_action::CpiContext { + address_tree_pubkey: address_tree_pubkey.to_bytes(), + set_context: false, + first_set_context: false, + in_tree_index: 1, + in_queue_index: 0, + out_queue_index: 0, + token_out_queue_index: 0, + assigned_account_index: 2, + read_only_address_trees: [0; 4], + }), + Some(cpi_context_pubkey), + ) + .unwrap(); + + let mut account_infos = cpi_accounts.to_account_infos(); + account_infos.push( + ctx.accounts + .compress_token_program_cpi_authority + .to_account_info(), + ); + account_infos.push(ctx.accounts.ctoken_program.to_account_info()); + account_infos.push(ctx.accounts.mint_authority.to_account_info()); + account_infos.push(ctx.accounts.mint_signer.to_account_info()); + account_infos.push(ctx.accounts.user.to_account_info()); + + invoke(&mint_action_instruction, &account_infos)?; + + user_record.close(ctx.accounts.rent_sponsor.to_account_info())?; + game_session.close(ctx.accounts.rent_sponsor.to_account_info())?; + + Ok(()) + } + + pub fn initialize_compression_config<'info>( + ctx: Context<'_, '_, '_, 'info, InitializeCompressionConfig<'info>>, + rent_sponsor: Pubkey, + address_space: Vec, + ) -> Result<()> { + let compression_authority = ctx.accounts.authority.key(); + let rent_config = light_compressible::rent::RentConfig::default(); + let write_top_up: u32 = 5_000; + light_sdk::compressible::process_initialize_compression_config_checked( + &ctx.accounts.config.to_account_info(), + &ctx.accounts.authority.to_account_info(), + &ctx.accounts.program_data.to_account_info(), + &rent_sponsor, + &compression_authority, + rent_config, + write_top_up, + address_space, + 0, + &ctx.accounts.payer.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + &crate::ID, + )?; + Ok(()) + } + + pub fn update_compression_config<'info>( + ctx: Context<'_, '_, '_, 'info, UpdateCompressionConfig<'info>>, + new_rent_sponsor: Option, + new_compression_authority: Option, + new_rent_config: Option, + new_write_top_up: Option, + new_address_space: Option>, + new_update_authority: Option, + ) -> Result<()> { + light_sdk::compressible::process_update_compression_config( + ctx.accounts.config.as_ref(), + ctx.accounts.authority.as_ref(), + new_update_authority.as_ref(), + new_rent_sponsor.as_ref(), + new_compression_authority.as_ref(), + new_rent_config, + new_write_top_up, + new_address_space, + &crate::ID, + )?; + Ok(()) + } + + pub fn decompress_accounts_idempotent<'info>( + ctx: Context<'_, '_, 'info, 'info, DecompressAccountsIdempotent<'info>>, + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + crate::processor::process_decompress_accounts_idempotent( + ctx.accounts, + ctx.remaining_accounts, + compressed_accounts, + proof, + system_accounts_offset, + ) + } + + pub fn compress_accounts_idempotent<'info>( + ctx: Context<'_, '_, 'info, 'info, CompressAccountsIdempotent<'info>>, + _proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec< + light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + >, + system_accounts_offset: u8, + ) -> Result<()> { + crate::processor::process_compress_accounts_idempotent( + ctx.accounts, + ctx.remaining_accounts, + compressed_accounts, + system_accounts_offset, + ) + } +} diff --git a/sdk-tests/csdk-anchor-derived-test/src/processor.rs b/sdk-tests/csdk-anchor-derived-test/src/processor.rs new file mode 100644 index 0000000000..bb569ec4a4 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/src/processor.rs @@ -0,0 +1,329 @@ +use anchor_lang::prelude::*; +use light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo; +use light_compressed_token_sdk::compat::PackedCTokenData; +use light_sdk::{ + compressible::{compress_account::prepare_account_for_compression, CompressibleConfig}, + cpi::v2::CpiAccounts, + instruction::{account_meta::CompressedAccountMetaNoLamportsNoAddress, ValidityProof}, + LightDiscriminator, +}; + +use crate::{ + instruction_accounts::{CompressAccountsIdempotent, DecompressAccountsIdempotent}, + state::{GameSession, PlaceholderRecord, UserRecord}, + variant::{CTokenAccountVariant, CompressedAccountData, CompressedAccountVariant}, + LIGHT_CPI_SIGNER, +}; + +impl light_sdk::compressible::HasTokenVariant for CompressedAccountData { + fn is_packed_ctoken(&self) -> bool { + matches!(self.data, CompressedAccountVariant::PackedCTokenData(_)) + } +} + +/// Empty struct since this test doesn't use data.* fields in PDA seeds +#[derive(Default)] +pub struct SeedParams; + +impl<'info> light_sdk::compressible::DecompressContext<'info> + for DecompressAccountsIdempotent<'info> +{ + type CompressedData = CompressedAccountData; + type PackedTokenData = PackedCTokenData; + type CompressedMeta = CompressedAccountMetaNoLamportsNoAddress; + type SeedParams = SeedParams; + + fn fee_payer(&self) -> &AccountInfo<'info> { + self.fee_payer.as_ref() + } + + fn config(&self) -> &AccountInfo<'info> { + &self.config + } + + fn rent_sponsor(&self) -> &AccountInfo<'info> { + self.rent_sponsor.as_ref() + } + + fn ctoken_rent_sponsor(&self) -> Option<&AccountInfo<'info>> { + self.ctoken_rent_sponsor.as_ref() + } + + fn ctoken_program(&self) -> Option<&AccountInfo<'info>> { + self.ctoken_program.as_ref() + } + + fn ctoken_cpi_authority(&self) -> Option<&AccountInfo<'info>> { + self.ctoken_cpi_authority.as_ref() + } + + fn ctoken_config(&self) -> Option<&AccountInfo<'info>> { + self.ctoken_config.as_ref() + } + + fn collect_pda_and_token<'b>( + &self, + cpi_accounts: &CpiAccounts<'b, 'info>, + address_space: Pubkey, + compressed_accounts: Vec, + solana_accounts: &[AccountInfo<'info>], + _seed_params: Option<&Self::SeedParams>, + ) -> std::result::Result< + ( + Vec, + Vec<(Self::PackedTokenData, Self::CompressedMeta)>, + ), + ProgramError, + > { + let post_system_offset = cpi_accounts.system_accounts_end_offset(); + let all_infos = cpi_accounts.account_infos(); + let post_system_accounts = &all_infos[post_system_offset..]; + + let mut compressed_pda_infos = Vec::new(); + let mut compressed_token_accounts = Vec::new(); + + for (i, compressed_data) in compressed_accounts.into_iter().enumerate() { + let meta = compressed_data.meta; + match compressed_data.data { + CompressedAccountVariant::PackedUserRecord(packed) => { + light_sdk::compressible::handle_packed_pda_variant::< + UserRecord, + _, + DecompressAccountsIdempotent<'info>, + SeedParams, + >( + self.rent_sponsor.as_ref(), + cpi_accounts, + address_space, + &solana_accounts[i], + i, + &packed, + &meta, + post_system_accounts, + &mut compressed_pda_infos, + &crate::ID, + self, + None, + )?; + } + CompressedAccountVariant::PackedGameSession(packed) => { + light_sdk::compressible::handle_packed_pda_variant::< + GameSession, + _, + DecompressAccountsIdempotent<'info>, + SeedParams, + >( + self.rent_sponsor.as_ref(), + cpi_accounts, + address_space, + &solana_accounts[i], + i, + &packed, + &meta, + post_system_accounts, + &mut compressed_pda_infos, + &crate::ID, + self, + None, + )?; + } + CompressedAccountVariant::PackedPlaceholderRecord(packed) => { + light_sdk::compressible::handle_packed_pda_variant::< + PlaceholderRecord, + _, + DecompressAccountsIdempotent<'info>, + SeedParams, + >( + self.rent_sponsor.as_ref(), + cpi_accounts, + address_space, + &solana_accounts[i], + i, + &packed, + &meta, + post_system_accounts, + &mut compressed_pda_infos, + &crate::ID, + self, + None, + )?; + } + CompressedAccountVariant::PackedCTokenData(mut data) => { + data.token_data.version = 3; + compressed_token_accounts.push((data, meta)); + } + CompressedAccountVariant::UserRecord(_) + | CompressedAccountVariant::GameSession(_) + | CompressedAccountVariant::PlaceholderRecord(_) + | CompressedAccountVariant::CTokenData(_) => { + unreachable!("Unpacked variants should not appear during decompression") + } + } + } + + Ok((compressed_pda_infos, compressed_token_accounts)) + } + + fn process_tokens<'b>( + &self, + _remaining_accounts: &[AccountInfo<'info>], + _fee_payer: &AccountInfo<'info>, + _ctoken_program: &AccountInfo<'info>, + _ctoken_rent_sponsor: &AccountInfo<'info>, + _ctoken_cpi_authority: &AccountInfo<'info>, + _ctoken_config: &AccountInfo<'info>, + _config: &AccountInfo<'info>, + ctoken_accounts: Vec<(Self::PackedTokenData, Self::CompressedMeta)>, + proof: light_sdk::instruction::ValidityProof, + cpi_accounts: &CpiAccounts<'b, 'info>, + post_system_accounts: &[AccountInfo<'info>], + has_pdas: bool, + ) -> std::result::Result<(), ProgramError> { + if ctoken_accounts.is_empty() { + return Ok(()); + } + + light_compressed_token_sdk::decompress_runtime::process_decompress_tokens_runtime::< + CTokenAccountVariant, + _, + >( + self, + _remaining_accounts, + _fee_payer, + _ctoken_program, + _ctoken_rent_sponsor, + _ctoken_cpi_authority, + _ctoken_config, + _config, + ctoken_accounts, + proof, + cpi_accounts, + post_system_accounts, + has_pdas, + &crate::ID, + )?; + + Ok(()) + } +} + +#[inline(never)] +pub fn process_decompress_accounts_idempotent<'info>( + accounts: &DecompressAccountsIdempotent<'info>, + remaining_accounts: &[AccountInfo<'info>], + compressed_accounts: Vec, + proof: ValidityProof, + system_accounts_offset: u8, +) -> Result<()> { + light_sdk::compressible::process_decompress_accounts_idempotent( + accounts, + remaining_accounts, + compressed_accounts, + proof, + system_accounts_offset, + LIGHT_CPI_SIGNER, + &crate::ID, + None, // No seed params needed for manual implementation + ) + .map_err(|e| e.into()) +} + +impl<'info> light_sdk::compressible::CompressContext<'info> for CompressAccountsIdempotent<'info> { + fn fee_payer(&self) -> &AccountInfo<'info> { + self.fee_payer.as_ref() + } + + fn config(&self) -> &AccountInfo<'info> { + &self.config + } + + fn rent_sponsor(&self) -> &AccountInfo<'info> { + &self.rent_sponsor + } + + fn compression_authority(&self) -> &AccountInfo<'info> { + &self.compression_authority + } + + fn compress_pda_account( + &self, + account_info: &AccountInfo<'info>, + meta: &CompressedAccountMetaNoLamportsNoAddress, + cpi_accounts: &CpiAccounts<'_, 'info>, + compression_config: &CompressibleConfig, + program_id: &Pubkey, + ) -> std::result::Result, ProgramError> { + let data = account_info.try_borrow_data()?; + let discriminator = &data[0..8]; + + match discriminator { + d if d == UserRecord::LIGHT_DISCRIMINATOR => { + drop(data); + let data_borrow = account_info.try_borrow_data()?; + let mut account_data = UserRecord::try_deserialize(&mut &data_borrow[..])?; + drop(data_borrow); + + let compressed_info = prepare_account_for_compression::( + program_id, + account_info, + &mut account_data, + meta, + cpi_accounts, + &compression_config.address_space, + )?; + Ok(Some(compressed_info)) + } + d if d == GameSession::LIGHT_DISCRIMINATOR => { + drop(data); + let data_borrow = account_info.try_borrow_data()?; + let mut account_data = GameSession::try_deserialize(&mut &data_borrow[..])?; + drop(data_borrow); + + let compressed_info = prepare_account_for_compression::( + program_id, + account_info, + &mut account_data, + meta, + cpi_accounts, + &compression_config.address_space, + )?; + Ok(Some(compressed_info)) + } + d if d == PlaceholderRecord::LIGHT_DISCRIMINATOR => { + drop(data); + let data_borrow = account_info.try_borrow_data()?; + let mut account_data = PlaceholderRecord::try_deserialize(&mut &data_borrow[..])?; + drop(data_borrow); + + let compressed_info = prepare_account_for_compression::( + program_id, + account_info, + &mut account_data, + meta, + cpi_accounts, + &compression_config.address_space, + )?; + Ok(Some(compressed_info)) + } + _ => Err(ProgramError::InvalidAccountData), + } + } +} + +#[inline(never)] +pub fn process_compress_accounts_idempotent<'info>( + accounts: &CompressAccountsIdempotent<'info>, + remaining_accounts: &[AccountInfo<'info>], + compressed_accounts: Vec, + system_accounts_offset: u8, +) -> Result<()> { + light_sdk::compressible::process_compress_pda_accounts_idempotent( + accounts, + remaining_accounts, + compressed_accounts, + system_accounts_offset, + LIGHT_CPI_SIGNER, + &crate::ID, + ) + .map_err(|e| e.into()) +} diff --git a/sdk-tests/csdk-anchor-derived-test/src/seeds.rs b/sdk-tests/csdk-anchor-derived-test/src/seeds.rs new file mode 100644 index 0000000000..532aef3ef8 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/src/seeds.rs @@ -0,0 +1,47 @@ +use anchor_lang::prelude::Pubkey; + +pub fn get_user_record_seeds(owner: &Pubkey) -> (Vec>, Pubkey) { + let seeds: &[&[u8]] = &[b"user_record", owner.as_ref()]; + let (pda, bump) = Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + for seed in seeds { + seeds_vec.push(seed.to_vec()); + } + seeds_vec.push(vec![bump]); + (seeds_vec, pda) +} + +pub fn get_game_session_seeds(session_id: u64) -> (Vec>, Pubkey) { + let session_id_bytes = session_id.to_le_bytes(); + let seeds: &[&[u8]] = &[b"game_session", session_id_bytes.as_ref()]; + let (pda, bump) = Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + for seed in seeds { + seeds_vec.push(seed.to_vec()); + } + seeds_vec.push(vec![bump]); + (seeds_vec, pda) +} + +pub fn get_placeholder_record_seeds(placeholder_id: u64) -> (Vec>, Pubkey) { + let placeholder_id_bytes = placeholder_id.to_le_bytes(); + let seeds: &[&[u8]] = &[b"placeholder_record", placeholder_id_bytes.as_ref()]; + let (pda, bump) = Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + for seed in seeds { + seeds_vec.push(seed.to_vec()); + } + seeds_vec.push(vec![bump]); + (seeds_vec, pda) +} + +pub fn get_ctoken_signer_seeds(user: &Pubkey, mint: &Pubkey) -> (Vec>, Pubkey) { + let seeds: &[&[u8]] = &[b"ctoken_signer", user.as_ref(), mint.as_ref()]; + let (pda, bump) = Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + for seed in seeds { + seeds_vec.push(seed.to_vec()); + } + seeds_vec.push(vec![bump]); + (seeds_vec, pda) +} diff --git a/sdk-tests/csdk-anchor-derived-test/src/state.rs b/sdk-tests/csdk-anchor-derived-test/src/state.rs new file mode 100644 index 0000000000..9a0b2347ae --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/src/state.rs @@ -0,0 +1,124 @@ +use anchor_lang::prelude::*; +use light_ctoken_types::instructions::mint_action::CompressedMintWithContext; +use light_sdk::{ + compressible::CompressionInfo, + instruction::{PackedAddressTreeInfo, ValidityProof}, + LightDiscriminator, LightHasher, +}; +use light_sdk_macros::{Compressible, CompressiblePack}; + +#[derive( + Default, Debug, LightHasher, LightDiscriminator, InitSpace, Compressible, CompressiblePack, +)] +#[account] +pub struct UserRecord { + #[skip] + pub compression_info: Option, + #[hash] + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub score: u64, +} + +#[derive( + Default, Debug, LightHasher, LightDiscriminator, InitSpace, Compressible, CompressiblePack, +)] +#[compress_as(start_time = 0, end_time = None, score = 0)] +#[account] +pub struct GameSession { + #[skip] + pub compression_info: Option, + pub session_id: u64, + #[hash] + pub player: Pubkey, + #[max_len(32)] + pub game_type: String, + pub start_time: u64, + pub end_time: Option, + pub score: u64, +} + +#[derive( + Default, Debug, LightHasher, LightDiscriminator, InitSpace, Compressible, CompressiblePack, +)] +#[account] +pub struct PlaceholderRecord { + #[skip] + pub compression_info: Option, + #[hash] + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub placeholder_id: u64, +} + +// Implement PdaSeedDerivation for UserRecord +impl light_sdk::compressible::PdaSeedDerivation for UserRecord { + fn derive_pda_seeds_with_accounts( + &self, + _program_id: &Pubkey, + _accounts: &A, + _seed_params: &S, + ) -> (Vec>, Pubkey) { + crate::seeds::get_user_record_seeds(&self.owner) + } +} + +// Implement PdaSeedDerivation for GameSession +impl light_sdk::compressible::PdaSeedDerivation for GameSession { + fn derive_pda_seeds_with_accounts( + &self, + _program_id: &Pubkey, + _accounts: &A, + _seed_params: &S, + ) -> (Vec>, Pubkey) { + crate::seeds::get_game_session_seeds(self.session_id) + } +} + +// Implement PdaSeedDerivation for PlaceholderRecord +impl light_sdk::compressible::PdaSeedDerivation for PlaceholderRecord { + fn derive_pda_seeds_with_accounts( + &self, + _program_id: &Pubkey, + _accounts: &A, + _seed_params: &S, + ) -> (Vec>, Pubkey) { + crate::seeds::get_placeholder_record_seeds(self.placeholder_id) + } +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct AccountCreationData { + pub user_name: String, + pub session_id: u64, + pub game_type: String, + pub mint_name: String, + pub mint_symbol: String, + pub mint_uri: String, + pub mint_decimals: u8, + pub mint_supply: u64, + pub mint_update_authority: Option, + pub mint_freeze_authority: Option, + pub additional_metadata: Option>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct TokenAccountInfo { + pub user: Pubkey, + pub mint: Pubkey, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct CompressionParams { + pub proof: ValidityProof, + pub user_compressed_address: [u8; 32], + pub user_address_tree_info: PackedAddressTreeInfo, + pub user_output_state_tree_index: u8, + pub game_compressed_address: [u8; 32], + pub game_address_tree_info: PackedAddressTreeInfo, + pub game_output_state_tree_index: u8, + pub mint_bump: u8, + pub mint_with_context: CompressedMintWithContext, +} diff --git a/sdk-tests/csdk-anchor-derived-test/src/variant.rs b/sdk-tests/csdk-anchor-derived-test/src/variant.rs new file mode 100644 index 0000000000..b46d785a18 --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/src/variant.rs @@ -0,0 +1,173 @@ +use anchor_lang::prelude::*; +use light_compressed_token_sdk::{ + compat::{CTokenData, PackedCTokenData}, + Pack as TokenPack, +}; +use light_sdk::{ + account::Size, + compressible::{CompressionInfo, HasCompressionInfo, Pack as SdkPack, Unpack as SdkUnpack}, + instruction::{account_meta::CompressedAccountMetaNoLamportsNoAddress, PackedAccounts}, + LightDiscriminator, +}; + +use crate::{ + instruction_accounts::DecompressAccountsIdempotent, + seeds::get_ctoken_signer_seeds, + state::{ + GameSession, PackedGameSession, PackedPlaceholderRecord, PackedUserRecord, + PlaceholderRecord, UserRecord, + }, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] +#[repr(u8)] +pub enum CTokenAccountVariant { + CTokenSigner = 0, +} + +impl light_compressed_token_sdk::CTokenSeedProvider for CTokenAccountVariant { + type Accounts<'info> = DecompressAccountsIdempotent<'info>; + + fn get_seeds<'a, 'info>( + &self, + accounts: &'a Self::Accounts<'info>, + _remaining_accounts: &'a [AccountInfo<'info>], + ) -> std::result::Result<(Vec>, Pubkey), ProgramError> { + match self { + CTokenAccountVariant::CTokenSigner => { + // Use the same convention as the mint/init path: ("ctoken_signer", user, mint) + std::result::Result::<(Vec>, Pubkey), ProgramError>::Ok( + get_ctoken_signer_seeds(&accounts.fee_payer.key(), &accounts.some_mint.key()), + ) + } + } + } + + fn get_authority_seeds<'a, 'info>( + &self, + _accounts: &'a Self::Accounts<'info>, + _remaining_accounts: &'a [AccountInfo<'info>], + ) -> std::result::Result<(Vec>, Pubkey), ProgramError> { + // Not used by the decompression runtime in this test. + std::result::Result::<(Vec>, Pubkey), ProgramError>::Err( + ProgramError::InvalidAccountData, + ) + } +} + +#[derive(Clone, Debug, AnchorSerialize, AnchorDeserialize)] +pub enum CompressedAccountVariant { + UserRecord(UserRecord), + PackedUserRecord(PackedUserRecord), + GameSession(GameSession), + PackedGameSession(PackedGameSession), + PlaceholderRecord(PlaceholderRecord), + PackedPlaceholderRecord(PackedPlaceholderRecord), + PackedCTokenData(PackedCTokenData), + CTokenData(CTokenData), +} + +impl Default for CompressedAccountVariant { + fn default() -> Self { + Self::UserRecord(UserRecord::default()) + } +} + +impl LightDiscriminator for CompressedAccountVariant { + const LIGHT_DISCRIMINATOR: [u8; 8] = [0; 8]; + const LIGHT_DISCRIMINATOR_SLICE: &'static [u8] = &Self::LIGHT_DISCRIMINATOR; +} + +impl HasCompressionInfo for CompressedAccountVariant { + fn compression_info(&self) -> &CompressionInfo { + match self { + Self::UserRecord(data) => data.compression_info(), + Self::GameSession(data) => data.compression_info(), + Self::PlaceholderRecord(data) => data.compression_info(), + _ => unreachable!(), + } + } + + fn compression_info_mut(&mut self) -> &mut CompressionInfo { + match self { + Self::UserRecord(data) => data.compression_info_mut(), + Self::GameSession(data) => data.compression_info_mut(), + Self::PlaceholderRecord(data) => data.compression_info_mut(), + _ => unreachable!(), + } + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + match self { + Self::UserRecord(data) => data.compression_info_mut_opt(), + Self::GameSession(data) => data.compression_info_mut_opt(), + Self::PlaceholderRecord(data) => data.compression_info_mut_opt(), + _ => unreachable!(), + } + } + + fn set_compression_info_none(&mut self) { + match self { + Self::UserRecord(data) => data.set_compression_info_none(), + Self::GameSession(data) => data.set_compression_info_none(), + Self::PlaceholderRecord(data) => data.set_compression_info_none(), + _ => unreachable!(), + } + } +} + +impl Size for CompressedAccountVariant { + fn size(&self) -> usize { + match self { + Self::UserRecord(data) => data.size(), + Self::GameSession(data) => data.size(), + Self::PlaceholderRecord(data) => data.size(), + _ => unreachable!(), + } + } +} + +impl SdkPack for CompressedAccountVariant { + type Packed = Self; + + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed { + match self { + Self::UserRecord(data) => Self::PackedUserRecord(data.pack(remaining_accounts)), + Self::GameSession(data) => Self::PackedGameSession(data.pack(remaining_accounts)), + Self::PlaceholderRecord(data) => { + Self::PackedPlaceholderRecord(data.pack(remaining_accounts)) + } + Self::CTokenData(data) => { + Self::PackedCTokenData(TokenPack::pack(data, remaining_accounts)) + } + _ => unreachable!(), + } + } +} + +impl SdkUnpack for CompressedAccountVariant { + type Unpacked = Self; + + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + match self { + Self::PackedUserRecord(data) => Ok(Self::UserRecord(data.unpack(remaining_accounts)?)), + Self::PackedGameSession(data) => { + Ok(Self::GameSession(data.unpack(remaining_accounts)?)) + } + Self::PackedPlaceholderRecord(data) => { + Ok(Self::PlaceholderRecord(data.unpack(remaining_accounts)?)) + } + Self::PackedCTokenData(data) => Ok(Self::PackedCTokenData(data.clone())), + _ => unreachable!(), + } + } +} + +#[derive(Clone, Debug, AnchorDeserialize, AnchorSerialize)] +pub struct CompressedAccountData { + pub meta: CompressedAccountMetaNoLamportsNoAddress, + pub data: CompressedAccountVariant, +} diff --git a/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs new file mode 100644 index 0000000000..e627f9654e --- /dev/null +++ b/sdk-tests/csdk-anchor-derived-test/tests/basic_test.rs @@ -0,0 +1,704 @@ +use anchor_lang::{AccountDeserialize, AnchorDeserialize, InstructionData, ToAccountMetas}; +use csdk_anchor_derived_test::{AccountCreationData, CompressionParams, GameSession, UserRecord}; +use light_compressed_account::address::derive_address; +use light_compressed_token_sdk::instructions::{ + create_compressed_mint::find_spl_mint_address, derive_compressed_mint_address, +}; +use light_ctoken_types::{ + instructions::mint_action::{CompressedMintInstructionData, CompressedMintWithContext}, + state::CompressedMintMetadata, +}; +use light_macros::pubkey; +use light_program_test::{ + program_test::{ + initialize_compression_config, setup_mock_program_data, LightProgramTest, TestRpc, + }, + AddressWithTree, Indexer, ProgramTestConfig, Rpc, +}; +use light_sdk::{ + compressible::CompressibleConfig, + instruction::{PackedAccounts, SystemAccountMetaConfig}, +}; +use light_sdk_types::C_TOKEN_PROGRAM_ID; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +const ADDRESS_SPACE: [Pubkey; 1] = [pubkey!("amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx")]; +const RENT_SPONSOR: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); + +#[tokio::test] +async fn test_create_decompress_compress() { + let program_id = csdk_anchor_derived_test::ID; + let mut config = + ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_derived_test", program_id)])); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + RENT_SPONSOR, + vec![ADDRESS_SPACE[0]], + &light_compressible_client::compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + let session_id = 42424u64; + let (user_record_pda, _user_record_bump) = + Pubkey::find_program_address(&[b"user_record", payer.pubkey().as_ref()], &program_id); + let (game_session_pda, _game_bump) = Pubkey::find_program_address( + &[b"game_session", session_id.to_le_bytes().as_ref()], + &program_id, + ); + + let mint_signer_pubkey = create_user_record_and_game_session( + &mut rpc, + &payer, + &program_id, + &config_pda, + &user_record_pda, + &game_session_pda, + session_id, + ) + .await; + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let compressed_user_record = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_user_record.address, + Some(user_compressed_address) + ); + assert!(compressed_user_record.data.is_some()); + + let user_buf = compressed_user_record.data.unwrap().data; + let user_record = UserRecord::deserialize(&mut &user_buf[..]).unwrap(); + + assert_eq!(user_record.name, "Combined User"); + assert_eq!(user_record.score, 11); + assert_eq!(user_record.owner, payer.pubkey()); + assert!(user_record.compression_info.is_none()); + + let compressed_game_session = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_game_session.address, + Some(game_compressed_address) + ); + assert!(compressed_game_session.data.is_some()); + + let game_buf = compressed_game_session.data.unwrap().data; + let game_session = GameSession::deserialize(&mut &game_buf[..]).unwrap(); + assert_eq!(game_session.session_id, session_id); + assert_eq!(game_session.game_type, "Test Game"); + assert_eq!(game_session.player, payer.pubkey()); + assert_eq!(game_session.score, 0); + assert!(game_session.compression_info.is_none()); + + let spl_mint = find_spl_mint_address(&mint_signer_pubkey).0; + let (_, token_account_address) = + csdk_anchor_derived_test::seeds::get_ctoken_signer_seeds(&payer.pubkey(), &spl_mint); + + let ctoken_accounts = rpc + .get_compressed_token_accounts_by_owner(&token_account_address, None, None) + .await + .unwrap() + .value; + + assert!( + !ctoken_accounts.items.is_empty(), + "Should have compressed token accounts" + ); + + // Test decompress PDAs (UserRecord + GameSession) + // Note: CToken decompression works but requires manual instruction building + // because the client helper doesn't handle mixed PDA+token packing correctly + rpc.warp_to_slot(100).unwrap(); + + decompress_pdas( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + &game_session_pda, + session_id, + 100, + ) + .await; + + // Test compress PDAs after decompression + rpc.warp_to_slot(200).unwrap(); + + compress_pdas( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + &game_session_pda, + session_id, + ) + .await; +} + +#[tokio::test] +async fn test_auto_compress_on_warp_forward() { + use light_compressible::rent::SLOTS_PER_EPOCH; + let program_id = csdk_anchor_derived_test::ID; + let config = + ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_derived_test", program_id)])); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + // Initialize compressible config + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + RENT_SPONSOR, + vec![ADDRESS_SPACE[0]], + &light_compressible_client::compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await + .expect("Initialize config should succeed"); + + // PDAs + let session_id = 5555u64; + let (user_record_pda, _) = + Pubkey::find_program_address(&[b"user_record", payer.pubkey().as_ref()], &program_id); + let (game_session_pda, _) = Pubkey::find_program_address( + &[b"game_session", session_id.to_le_bytes().as_ref()], + &program_id, + ); + + // Create + compress initial state via helper (combined create path) + let _mint_signer_pubkey = create_user_record_and_game_session( + &mut rpc, + &payer, + &program_id, + &config_pda, + &user_record_pda, + &game_session_pda, + session_id, + ) + .await; + + // Decompress both PDAs + rpc.warp_to_slot(100).unwrap(); + decompress_pdas( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + &game_session_pda, + session_id, + 100, + ) + .await; + + // Warp two epochs to ensure PDAs are compressible + rpc.warp_slot_forward(SLOTS_PER_EPOCH * 2).await.unwrap(); + + // Also invoke auto-compress directly to ensure it's executed in this test context + light_program_test::compressible::auto_compress_program_pdas(&mut rpc, program_id) + .await + .unwrap(); + + // After auto-compress, PDAs should be closed or emptied + let user_acc = rpc.get_account(user_record_pda).await.unwrap(); + let game_acc = rpc.get_account(game_session_pda).await.unwrap(); + let user_closed = user_acc.is_none() + || user_acc + .as_ref() + .map(|a| a.data.is_empty() || a.lamports == 0) + .unwrap_or(true); + let game_closed = game_acc.is_none() + || game_acc + .as_ref() + .map(|a| a.data.is_empty() || a.lamports == 0) + .unwrap_or(true); + assert!( + user_closed && game_closed, + "Auto-compress should close PDAs" + ); +} + +#[allow(clippy::too_many_arguments)] +async fn decompress_pdas( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, + expected_slot: u64, +) { + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + // Get compressed PDA accounts + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let c_user_pda = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + let c_game_pda = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let user_account_data = c_user_pda.data.as_ref().unwrap(); + let c_user_record = UserRecord::deserialize(&mut &user_account_data.data[..]).unwrap(); + + let game_account_data = c_game_pda.data.as_ref().unwrap(); + let c_game_session = GameSession::deserialize(&mut &game_account_data.data[..]).unwrap(); + + let rpc_result = rpc + .get_validity_proof(vec![c_user_pda.hash, c_game_pda.hash], vec![], None) + .await + .unwrap() + .value; + + let instruction = + light_compressible_client::compressible_instruction::decompress_accounts_idempotent( + program_id, + &light_compressible_client::compressible_instruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &[*user_record_pda, *game_session_pda], + &[ + ( + c_user_pda.clone(), + csdk_anchor_derived_test::CompressedAccountVariant::UserRecord(c_user_record), + ), + ( + c_game_pda.clone(), + csdk_anchor_derived_test::CompressedAccountVariant::GameSession(c_game_session), + ), + ], + &csdk_anchor_derived_test::accounts::DecompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(program_id, 0).0, + rent_sponsor: payer.pubkey(), + ctoken_rent_sponsor: None, + ctoken_config: None, + ctoken_program: None, + ctoken_cpi_authority: None, + some_mint: payer.pubkey(), + } + .to_account_metas(None), + rpc_result, + ) + .unwrap(); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + + assert!(result.is_ok(), "Decompress PDAs transaction should succeed"); + + // Verify user record decompressed + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_pda_account.is_some(), + "User PDA should exist after decompression" + ); + let decompressed_user_record = + UserRecord::try_deserialize(&mut &user_pda_account.unwrap().data[..]).unwrap(); + assert_eq!(decompressed_user_record.name, "Combined User"); + assert_eq!(decompressed_user_record.score, 11); + assert_eq!(decompressed_user_record.owner, payer.pubkey()); + assert!(!decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .last_claimed_slot(), + expected_slot + ); + + // Verify game session decompressed + let game_pda_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert!( + game_pda_account.is_some(), + "Game PDA should exist after decompression" + ); + let decompressed_game_session = + GameSession::try_deserialize(&mut &game_pda_account.unwrap().data[..]).unwrap(); + assert_eq!(decompressed_game_session.session_id, session_id); + assert_eq!(decompressed_game_session.game_type, "Test Game"); + assert_eq!(decompressed_game_session.player, payer.pubkey()); + assert!(!decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .last_claimed_slot(), + expected_slot + ); + + // Verify compressed PDA accounts are empty + let compressed_user = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + assert!( + compressed_user.data.unwrap().data.is_empty(), + "Compressed user should be empty after decompression" + ); + + let compressed_game = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + assert!( + compressed_game.data.unwrap().data.is_empty(), + "Compressed game should be empty after decompression" + ); +} + +#[allow(clippy::too_many_arguments)] +async fn compress_pdas( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, +) { + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + // Get PDA accounts + let _user_pda_account = rpc + .get_account(*user_record_pda) + .await + .unwrap() + .expect("User PDA should exist before compression"); + let _game_pda_account = rpc + .get_account(*game_session_pda) + .await + .unwrap() + .expect("Game PDA should exist before compression"); + + // Get compressed account hashes for proof + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let compressed_user = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + let compressed_game = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let _rpc_result = rpc + .get_validity_proof( + vec![compressed_user.hash, compressed_game.hash], + vec![], + None, + ) + .await + .unwrap() + .value; + + // TODO: remove in separate pr + // let instruction = + // light_compressible_client::compressible_instruction::compress_accounts_idempotent( + // program_id, + // csdk_anchor_derived_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, + // &[*user_record_pda, *game_session_pda], + // &[user_pda_account, game_pda_account], + // &csdk_anchor_derived_test::accounts::CompressAccountsIdempotent { + // fee_payer: payer.pubkey(), + // config: CompressibleConfig::derive_pda(program_id, 0).0, + // rent_sponsor: RENT_SPONSOR, + // compression_authority: payer.pubkey(), + // } + // .to_account_metas(None), + // rpc_result, + // ) + // .unwrap(); + + // let result = rpc + // .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + // .await; + + // assert!(result.is_ok(), "Compress PDAs transaction should succeed"); + + rpc.warp_slot_forward(light_compressible::rent::SLOTS_PER_EPOCH * 2) + .await + .unwrap(); + + // Verify PDAs are closed + let user_pda_after = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_pda_after.is_none(), + "User PDA should be closed after compression" + ); + + let game_pda_after = rpc.get_account(*game_session_pda).await.unwrap(); + assert!( + game_pda_after.is_none(), + "Game PDA should be closed after compression" + ); + + // Verify compressed PDA accounts have data + let compressed_user_after = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + assert_eq!(compressed_user_after.address, Some(user_compressed_address)); + let user_buf = compressed_user_after.data.unwrap().data; + let user_record = UserRecord::deserialize(&mut &user_buf[..]).unwrap(); + assert_eq!(user_record.name, "Combined User"); + assert_eq!(user_record.score, 11); + assert_eq!(user_record.owner, payer.pubkey()); + assert!(user_record.compression_info.is_none()); + + let compressed_game_after = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + assert_eq!(compressed_game_after.address, Some(game_compressed_address)); + let game_buf = compressed_game_after.data.unwrap().data; + let game_session = GameSession::deserialize(&mut &game_buf[..]).unwrap(); + assert_eq!(game_session.session_id, session_id); + assert_eq!(game_session.game_type, "Test Game"); + assert!(game_session.compression_info.is_none()); +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_user_record_and_game_session( + rpc: &mut LightProgramTest, + user: &Keypair, + program_id: &Pubkey, + config_pda: &Pubkey, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, +) -> Pubkey { + let state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + let mut remaining_accounts = PackedAccounts::default(); + let system_config = SystemAccountMetaConfig::new_with_cpi_context( + *program_id, + state_tree_info.cpi_context.unwrap(), + ); + let _ = remaining_accounts.add_system_accounts_v2(system_config); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + let decimals = 6u8; + let mint_authority_keypair = Keypair::new(); + let mint_authority = mint_authority_keypair.pubkey(); + let freeze_authority = mint_authority; + let mint_signer = Keypair::new(); + let compressed_mint_address = + derive_compressed_mint_address(&mint_signer.pubkey(), &address_tree_pubkey); + + let (spl_mint, mint_bump) = find_spl_mint_address(&mint_signer.pubkey()); + let accounts = csdk_anchor_derived_test::accounts::CreateUserRecordAndGameSession { + user: user.pubkey(), + user_record: *user_record_pda, + game_session: *game_session_pda, + mint_signer: mint_signer.pubkey(), + ctoken_program: C_TOKEN_PROGRAM_ID.into(), + system_program: solana_sdk::system_program::ID, + config: *config_pda, + rent_sponsor: RENT_SPONSOR, + mint_authority, + compress_token_program_cpi_authority: light_compressed_token_types::CPI_AUTHORITY_PDA + .into(), + }; + + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![ + AddressWithTree { + address: user_compressed_address, + tree: address_tree_pubkey, + }, + AddressWithTree { + address: game_compressed_address, + tree: address_tree_pubkey, + }, + AddressWithTree { + address: compressed_mint_address, + tree: address_tree_pubkey, + }, + ], + None, + ) + .await + .unwrap() + .value; + + let user_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + let game_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + let _mint_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + + let packed_tree_infos = rpc_result.pack_tree_infos(&mut remaining_accounts); + + let user_address_tree_info = packed_tree_infos.address_trees[0]; + let game_address_tree_info = packed_tree_infos.address_trees[1]; + let mint_address_tree_info = packed_tree_infos.address_trees[2]; + + let (system_accounts, _, _) = remaining_accounts.to_account_metas(); + + let instruction_data = csdk_anchor_derived_test::instruction::CreateUserRecordAndGameSession { + account_data: AccountCreationData { + user_name: "Combined User".to_string(), + session_id, + game_type: "Test Game".to_string(), + mint_name: "Test Game Token".to_string(), + mint_symbol: "TGT".to_string(), + mint_uri: "https://example.com/token.json".to_string(), + mint_decimals: 9, + mint_supply: 1_000_000_000, + mint_update_authority: Some(mint_authority), + mint_freeze_authority: Some(freeze_authority), + additional_metadata: None, + }, + compression_params: CompressionParams { + proof: rpc_result.proof, + user_compressed_address, + user_address_tree_info, + user_output_state_tree_index, + game_compressed_address, + game_address_tree_info, + game_output_state_tree_index, + mint_bump, + mint_with_context: CompressedMintWithContext { + leaf_index: 0, + prove_by_index: false, + root_index: mint_address_tree_info.root_index, + address: compressed_mint_address, + mint: CompressedMintInstructionData { + supply: 0, + decimals, + metadata: CompressedMintMetadata { + version: 3, + mint: spl_mint.into(), + spl_mint_initialized: false, + }, + mint_authority: Some(mint_authority.into()), + freeze_authority: Some(freeze_authority.into()), + extensions: None, + }, + }, + }, + }; + + let instruction = Instruction { + program_id: *program_id, + accounts: [accounts.to_account_metas(None), system_accounts].concat(), + data: instruction_data.data(), + }; + + let result = rpc + .create_and_send_transaction( + &[instruction], + &user.pubkey(), + &[user, &mint_signer, &mint_authority_keypair], + ) + .await; + + assert!( + result.is_ok(), + "Combined creation transaction should succeed: {:?}", + result + ); + + mint_signer.pubkey() +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/Anchor.toml b/sdk-tests/csdk-anchor-full-derived-test/Anchor.toml new file mode 100644 index 0000000000..d622bbf2e4 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/Anchor.toml @@ -0,0 +1,17 @@ +[features] +resolution = true +skip-lint = false + +[programs.localnet] +csdk_anchor_full_derived_test = "FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah" + +[registry] +url = "https://api.apr.dev" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test-sbf -p csdk-anchor-full-derived-test -- --nocapture" + diff --git a/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml new file mode 100644 index 0000000000..72c56a482a --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "csdk-anchor-full-derived-test" +version = "0.1.0" +description = "Anchor program test using add_compressible_instructions macro for all compressible instructions" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "csdk_anchor_full_derived_test" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build"] +test-sbf = [] + +[dependencies] +light-sdk = { workspace = true, features = ["anchor", "idl-build", "v2", "anchor-discriminator", "cpi-context"] } +light-sdk-types = { workspace = true, features = ["v2", "cpi-context"] } +light-hasher = { workspace = true, features = ["solana"] } +solana-program = { workspace = true } +solana-program-error = { workspace = true } +solana-account-info = { workspace = true } +solana-pubkey = { workspace = true } +light-macros = { workspace = true, features = ["solana"] } +light-sdk-macros = { workspace = true } +borsh = { workspace = true } +light-compressed-account = { workspace = true, features = ["solana"] } +anchor-lang = { workspace = true, features = ["idl-build"] } +anchor-spl = { version = "=0.31.1", git = "https://github.com/lightprotocol/anchor", rev = "d8a2b3d9", features = ["memo", "metadata", "idl-build"] } +light-ctoken-types = { workspace = true, features = ["anchor"] } +light-compressed-token-sdk = { workspace = true, features = ["anchor"] } +light-compressed-token-types = { workspace = true, features = ["anchor"] } +light-compressible = { workspace = true, features = ["anchor"] } + +[dev-dependencies] +light-token-client = { workspace = true } +light-program-test = { workspace = true, features = ["devenv"] } +light-client = { workspace = true, features = ["v2"] } +light-compressible-client = { workspace = true, features = ["anchor"] } +light-test-utils = { workspace = true } +tokio = { workspace = true } +solana-sdk = { workspace = true } +solana-logger = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +solana-signature = { workspace = true } +solana-signer = { workspace = true } +solana-keypair = { workspace = true } +solana-account = { workspace = true } +bincode = "1.3" + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] + diff --git a/sdk-tests/csdk-anchor-full-derived-test/Xargo.toml b/sdk-tests/csdk-anchor-full-derived-test/Xargo.toml new file mode 100644 index 0000000000..2e540a4b96 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/Xargo.toml @@ -0,0 +1,3 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] + diff --git a/sdk-tests/csdk-anchor-full-derived-test/package.json b/sdk-tests/csdk-anchor-full-derived-test/package.json new file mode 100644 index 0000000000..cabe18b832 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/package.json @@ -0,0 +1,11 @@ +{ + "name": "@lightprotocol/csdk-anchor-full-derived-test", + "version": "0.1.0", + "license": "Apache-2.0", + "scripts": { + "build": "cargo build-sbf", + "test": "cargo test-sbf -p csdk-anchor-full-derived-test -- --nocapture" + }, + "nx": {} +} + diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/errors.rs b/sdk-tests/csdk-anchor-full-derived-test/src/errors.rs new file mode 100644 index 0000000000..ba644872ef --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/errors.rs @@ -0,0 +1,21 @@ +use anchor_lang::prelude::{Error, ProgramError}; + +#[repr(u32)] +pub enum ErrorCode { + RentRecipientMismatch, + InvalidAuthority, + InvalidMintAuthority, + InvalidFeePayer, +} + +impl From for ProgramError { + fn from(e: ErrorCode) -> Self { + ProgramError::Custom(e as u32) + } +} + +impl From for Error { + fn from(e: ErrorCode) -> Self { + Error::from(ProgramError::from(e)) + } +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs new file mode 100644 index 0000000000..adb38a181d --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs @@ -0,0 +1,72 @@ +use anchor_lang::prelude::*; + +use crate::state::*; + +#[derive(Accounts)] +#[instruction(account_data: AccountCreationData)] +pub struct CreateUserRecordAndGameSession<'info> { + #[account(mut)] + pub user: Signer<'info>, + + /// The mint signer used for PDA derivation + pub mint_signer: Signer<'info>, + + #[account( + init, + payer = user, + // Space: discriminator(8) + owner(32) + name_len(4) + name(32) + score(8) + category_id(8) = 92 bytes + space = 8 + 32 + 4 + 32 + 8 + 8, + seeds = [ + b"user_record", + authority.key().as_ref(), + mint_authority.key().as_ref(), + account_data.owner.as_ref(), + account_data.category_id.to_le_bytes().as_ref() + ], + bump, + )] + pub user_record: Account<'info, UserRecord>, + #[account( + init, + payer = user, + // Space: discriminator(8) + session_id(8) + player(32) + game_type_len(4) + + // game_type(32) + start_time(8) + end_time(1+8) + score(8) = 109 bytes + space = 8 + 8 + 32 + 4 + 32 + 8 + 9 + 8, + seeds = [ + b"game_session", + crate::max_key(&user.key(), &authority.key()).as_ref(), + account_data.session_id.to_le_bytes().as_ref() + ], + bump, + )] + pub game_session: Account<'info, GameSession>, + + /// Authority signer used in PDA seeds + pub authority: Signer<'info>, + + /// Mint authority signer used in PDA seeds + pub mint_authority: Signer<'info>, + + /// Some account used in PlaceholderRecord PDA seeds + /// CHECK: Used as seed component + pub some_account: AccountInfo<'info>, + + /// Compressed token program + /// CHECK: Program ID validated using C_TOKEN_PROGRAM_ID constant + pub ctoken_program: UncheckedAccount<'info>, + + /// CHECK: CPI authority of the compressed token program + pub compress_token_program_cpi_authority: UncheckedAccount<'info>, + + /// Needs to be here for the init anchor macro to work. + pub system_program: Program<'info, System>, + + /// Global compressible config + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_sponsor: AccountInfo<'info>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs new file mode 100644 index 0000000000..81b7606c8e --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -0,0 +1,245 @@ +#![allow(deprecated)] + +use anchor_lang::prelude::*; +use light_sdk::{derive_light_cpi_signer, derive_light_rent_sponsor_pda}; +use light_sdk_macros::add_compressible_instructions; +use light_sdk_types::CpiSigner; + +pub mod errors; +pub mod instruction_accounts; +pub mod state; + +pub use instruction_accounts::*; +pub use state::{ + AccountCreationData, CompressionParams, GameSession, PackedGameSession, + PackedPlaceholderRecord, PackedUserRecord, PlaceholderRecord, UserRecord, +}; + +// Example helper expression usable in seeds +#[inline] +pub fn max_key(left: &Pubkey, right: &Pubkey) -> [u8; 32] { + if left > right { + left.to_bytes() + } else { + right.to_bytes() + } +} + +declare_id!("FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah"); + +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah"); + +/// Derive a program-owned rent sponsor PDA (version = 1 by default). +pub const PROGRAM_RENT_SPONSOR_DATA: ([u8; 32], u8) = + derive_light_rent_sponsor_pda!("FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah", 1); + +/// Returns the program's rent sponsor PDA as a Pubkey. +#[inline] +pub fn program_rent_sponsor() -> Pubkey { + Pubkey::from(PROGRAM_RENT_SPONSOR_DATA.0) +} + +#[add_compressible_instructions( + // Complex PDA account types with seed specifications using BOTH ctx.accounts.* AND data.* + // UserRecord: uses ctx accounts (authority, mint_authority) + data fields (owner, category_id) + UserRecord = ("user_record", ctx.authority, ctx.mint_authority, data.owner, data.category_id.to_le_bytes()), + // GameSession: uses max_key expression with ctx.accounts + data.session_id + GameSession = ("game_session", max_key(&ctx.user.key(), &ctx.authority.key()), data.session_id.to_le_bytes()), + // PlaceholderRecord: mixes ctx accounts and data for seeds + PlaceholderRecord = ("placeholder_record", ctx.authority, ctx.some_account, data.placeholder_id.to_le_bytes(), data.counter.to_le_bytes()), + // Token variant (CToken account) with authority for compression signing + CTokenSigner = (is_token, "ctoken_signer", ctx.fee_payer, ctx.mint, authority = LIGHT_CPI_SIGNER), + // Instruction data fields used in seed expressions above + owner = Pubkey, + category_id = u64, + session_id = u64, + placeholder_id = u64, + counter = u32, +)] +#[program] +pub mod csdk_anchor_full_derived_test { + #![allow(clippy::too_many_arguments)] + use anchor_lang::solana_program::{program::invoke, sysvar::clock::Clock}; + use light_compressed_token_sdk::instructions::{ + create_mint_action_cpi, find_spl_mint_address, MintActionInputs, + }; + use light_sdk::{ + compressible::{ + compress_account_on_init::prepare_compressed_account_on_init, CompressibleConfig, + }, + cpi::{ + v2::{CpiAccounts, LightSystemProgramCpi}, + InvokeLightSystemProgram, LightCpiInstruction, + }, + }; + use light_sdk_types::{ + cpi_accounts::CpiAccountsConfig, cpi_context_write::CpiContextWriteAccounts, + }; + + use super::*; + use crate::{ + errors::ErrorCode, + state::{GameSession, UserRecord}, + LIGHT_CPI_SIGNER, + }; + + pub fn create_user_record_and_game_session<'info>( + ctx: Context<'_, '_, '_, 'info, CreateUserRecordAndGameSession<'info>>, + account_data: AccountCreationData, + compression_params: CompressionParams, + ) -> Result<()> { + let user_record = &mut ctx.accounts.user_record; + let game_session = &mut ctx.accounts.game_session; + + let config = CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; + + if ctx.accounts.rent_sponsor.key() != config.rent_sponsor { + return Err(ErrorCode::RentRecipientMismatch.into()); + } + + // Populate UserRecord + user_record.owner = account_data.owner; + user_record.name = account_data.user_name.clone(); + user_record.score = 11; + user_record.category_id = account_data.category_id; + + // Populate GameSession + game_session.session_id = account_data.session_id; + game_session.player = ctx.accounts.user.key(); + game_session.game_type = account_data.game_type.clone(); + game_session.start_time = Clock::get()?.unix_timestamp as u64; + game_session.end_time = None; + game_session.score = 0; + + let cpi_accounts = CpiAccounts::new_with_config( + ctx.accounts.user.as_ref(), + ctx.remaining_accounts, + CpiAccountsConfig::new_with_cpi_context(LIGHT_CPI_SIGNER), + ); + let cpi_context_pubkey = cpi_accounts.cpi_context().unwrap().key(); + let cpi_context_account = cpi_accounts.cpi_context().unwrap(); + + let user_new_address_params = compression_params + .user_address_tree_info + .into_new_address_params_assigned_packed(user_record.key().to_bytes().into(), Some(0)); + let game_new_address_params = compression_params + .game_address_tree_info + .into_new_address_params_assigned_packed(game_session.key().to_bytes().into(), Some(1)); + + let mut all_compressed_infos = Vec::new(); + + let user_record_info = user_record.to_account_info(); + let user_record_data_mut = &mut **user_record; + let user_compressed_info = prepare_compressed_account_on_init::( + &user_record_info, + user_record_data_mut, + &config, + compression_params.user_compressed_address, + user_new_address_params, + compression_params.user_output_state_tree_index, + &cpi_accounts, + &config.address_space, + true, + )?; + all_compressed_infos.push(user_compressed_info); + + let game_session_info = game_session.to_account_info(); + let game_session_data_mut = &mut **game_session; + let game_compressed_info = prepare_compressed_account_on_init::( + &game_session_info, + game_session_data_mut, + &config, + compression_params.game_compressed_address, + game_new_address_params, + compression_params.game_output_state_tree_index, + &cpi_accounts, + &config.address_space, + true, + )?; + all_compressed_infos.push(game_compressed_info); + + let cpi_context_accounts = CpiContextWriteAccounts { + fee_payer: cpi_accounts.fee_payer(), + authority: cpi_accounts.authority().unwrap(), + cpi_context: cpi_context_account, + cpi_signer: LIGHT_CPI_SIGNER, + }; + LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, compression_params.proof) + .with_new_addresses(&[user_new_address_params, game_new_address_params]) + .with_account_infos(&all_compressed_infos) + .write_to_cpi_context_first() + .invoke_write_to_cpi_context_first(cpi_context_accounts)?; + + let mint = find_spl_mint_address(&ctx.accounts.mint_signer.key()).0; + + // Use the generated client seed function for CToken signer (generated by add_compressible_instructions macro) + let (_, token_account_address) = get_ctokensigner_seeds(&ctx.accounts.user.key(), &mint); + + let actions = vec![ + light_compressed_token_sdk::instructions::mint_action::MintActionType::MintTo { + recipients: vec![ + light_compressed_token_sdk::instructions::mint_action::MintToRecipient { + recipient: token_account_address, + amount: 1000, + }, + ], + token_account_version: 3, + }, + ]; + + let output_queue = *cpi_accounts.tree_accounts().unwrap()[0].key; + let address_tree_pubkey = *cpi_accounts.tree_accounts().unwrap()[1].key; + + let mint_action_inputs = MintActionInputs { + compressed_mint_inputs: compression_params.mint_with_context.clone(), + mint_seed: ctx.accounts.mint_signer.key(), + mint_bump: Some(compression_params.mint_bump), + create_mint: true, + authority: ctx.accounts.mint_authority.key(), + payer: ctx.accounts.user.key(), + proof: compression_params.proof.into(), + actions, + input_queue: None, + output_queue, + tokens_out_queue: Some(output_queue), + address_tree_pubkey, + token_pool: None, + }; + + let mint_action_instruction = create_mint_action_cpi( + mint_action_inputs, + Some(light_ctoken_types::instructions::mint_action::CpiContext { + address_tree_pubkey: address_tree_pubkey.to_bytes(), + set_context: false, + first_set_context: false, + in_tree_index: 1, + in_queue_index: 0, + out_queue_index: 0, + token_out_queue_index: 0, + assigned_account_index: 2, + read_only_address_trees: [0; 4], + }), + Some(cpi_context_pubkey), + ) + .unwrap(); + + let mut account_infos = cpi_accounts.to_account_infos(); + account_infos.push( + ctx.accounts + .compress_token_program_cpi_authority + .to_account_info(), + ); + account_infos.push(ctx.accounts.ctoken_program.to_account_info()); + account_infos.push(ctx.accounts.mint_authority.to_account_info()); + account_infos.push(ctx.accounts.mint_signer.to_account_info()); + account_infos.push(ctx.accounts.user.to_account_info()); + + invoke(&mint_action_instruction, &account_infos)?; + + user_record.close(ctx.accounts.rent_sponsor.to_account_info())?; + game_session.close(ctx.accounts.rent_sponsor.to_account_info())?; + + Ok(()) + } +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state.rs new file mode 100644 index 0000000000..b6a12a44cd --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state.rs @@ -0,0 +1,95 @@ +use anchor_lang::prelude::*; +use light_ctoken_types::instructions::mint_action::CompressedMintWithContext; +use light_sdk::{ + compressible::CompressionInfo, + instruction::{PackedAddressTreeInfo, ValidityProof}, + LightDiscriminator, LightHasher, +}; +use light_sdk_macros::{Compressible, CompressiblePack}; + +#[derive( + Default, Debug, LightHasher, LightDiscriminator, InitSpace, Compressible, CompressiblePack, +)] +#[account] +pub struct UserRecord { + #[skip] + pub compression_info: Option, + #[hash] + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub score: u64, + pub category_id: u64, +} + +#[derive( + Default, Debug, LightHasher, LightDiscriminator, InitSpace, Compressible, CompressiblePack, +)] +#[compress_as(start_time = 0, end_time = None, score = 0)] +#[account] +pub struct GameSession { + #[skip] + pub compression_info: Option, + pub session_id: u64, + #[hash] + pub player: Pubkey, + #[max_len(32)] + pub game_type: String, + pub start_time: u64, + pub end_time: Option, + pub score: u64, +} + +#[derive( + Default, Debug, LightHasher, LightDiscriminator, InitSpace, Compressible, CompressiblePack, +)] +#[account] +pub struct PlaceholderRecord { + #[skip] + pub compression_info: Option, + #[hash] + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub placeholder_id: u64, + pub counter: u32, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct AccountCreationData { + // Instruction data fields (accounts come from ctx.accounts.*) + pub owner: Pubkey, + pub category_id: u64, + pub user_name: String, + pub session_id: u64, + pub game_type: String, + pub placeholder_id: u64, + pub counter: u32, + pub mint_name: String, + pub mint_symbol: String, + pub mint_uri: String, + pub mint_decimals: u8, + pub mint_supply: u64, + pub mint_update_authority: Option, + pub mint_freeze_authority: Option, + pub additional_metadata: Option>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct TokenAccountInfo { + pub user: Pubkey, + pub mint: Pubkey, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct CompressionParams { + pub proof: ValidityProof, + pub user_compressed_address: [u8; 32], + pub user_address_tree_info: PackedAddressTreeInfo, + pub user_output_state_tree_index: u8, + pub game_compressed_address: [u8; 32], + pub game_address_tree_info: PackedAddressTreeInfo, + pub game_output_state_tree_index: u8, + pub mint_bump: u8, + pub mint_with_context: CompressedMintWithContext, +} 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 new file mode 100644 index 0000000000..9e028709a9 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -0,0 +1,364 @@ +use anchor_lang::{AnchorDeserialize, InstructionData, ToAccountMetas}; +use csdk_anchor_full_derived_test::{ + AccountCreationData, CompressionParams, GameSession, UserRecord, +}; +use light_compressed_account::address::derive_address; +use light_compressed_token_sdk::instructions::{ + create_compressed_mint::find_spl_mint_address, derive_compressed_mint_address, +}; +use light_ctoken_types::{ + instructions::mint_action::{CompressedMintInstructionData, CompressedMintWithContext}, + state::CompressedMintMetadata, +}; +use light_macros::pubkey; +use light_program_test::{ + program_test::{setup_mock_program_data, LightProgramTest}, + AddressWithTree, Indexer, ProgramTestConfig, Rpc, +}; +use light_sdk::{ + compressible::CompressibleConfig, + instruction::{PackedAccounts, SystemAccountMetaConfig}, +}; +use light_sdk_types::C_TOKEN_PROGRAM_ID; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +const ADDRESS_SPACE: [Pubkey; 1] = [pubkey!("amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx")]; +const RENT_SPONSOR: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); + +#[tokio::test] +async fn test_create_with_complex_seeds() { + let program_id = csdk_anchor_full_derived_test::ID; + let mut config = ProgramTestConfig::new_v2( + true, + Some(vec![("csdk_anchor_full_derived_test", program_id)]), + ); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + // Initialize compression config using the macro-generated instruction + let config_instruction = + csdk_anchor_full_derived_test::instruction::InitializeCompressionConfig { + rent_sponsor: RENT_SPONSOR, + compression_authority: payer.pubkey(), + rent_config: light_compressible::rent::RentConfig::default(), + write_top_up: 5_000, + address_space: vec![ADDRESS_SPACE[0]], + }; + let config_accounts = csdk_anchor_full_derived_test::accounts::InitializeCompressionConfig { + payer: payer.pubkey(), + config: config_pda, + program_data: _program_data_pda, + authority: payer.pubkey(), + system_program: solana_sdk::system_program::ID, + }; + let instruction = Instruction { + program_id, + accounts: config_accounts.to_account_metas(None), + data: config_instruction.data(), + }; + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + assert!( + result.is_ok(), + "Initialize config should succeed: {:?}", + result + ); + + // Create additional signers for complex seeds + let authority = Keypair::new(); + let mint_authority_keypair = Keypair::new(); + let some_account = Keypair::new(); + + let session_id = 42424u64; + let category_id = 777u64; + + // Calculate PDAs with complex seeds using ctx accounts + let (user_record_pda, _user_record_bump) = Pubkey::find_program_address( + &[ + b"user_record", + authority.pubkey().as_ref(), + mint_authority_keypair.pubkey().as_ref(), + payer.pubkey().as_ref(), // owner from instruction data + category_id.to_le_bytes().as_ref(), + ], + &program_id, + ); + + // GameSession uses max_key(ctx.user, ctx.authority) for the seed + let max_key_result = + csdk_anchor_full_derived_test::max_key(&payer.pubkey(), &authority.pubkey()); + let (game_session_pda, _game_bump) = Pubkey::find_program_address( + &[ + b"game_session", + max_key_result.as_ref(), + session_id.to_le_bytes().as_ref(), + ], + &program_id, + ); + + let mint_signer_pubkey = create_user_record_and_game_session( + &mut rpc, + &payer, + &program_id, + &config_pda, + &user_record_pda, + &game_session_pda, + &authority, + &mint_authority_keypair, + &some_account, + session_id, + category_id, + ) + .await; + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + // Verify compressed user record + let compressed_user_record = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_user_record.address, + Some(user_compressed_address) + ); + assert!(compressed_user_record.data.is_some()); + + let user_buf = compressed_user_record.data.unwrap().data; + let user_record = UserRecord::deserialize(&mut &user_buf[..]).unwrap(); + + assert_eq!(user_record.name, "Complex User"); + assert_eq!(user_record.score, 11); + assert_eq!(user_record.owner, payer.pubkey()); + assert_eq!(user_record.category_id, category_id); + assert!(user_record.compression_info.is_none()); + + // Verify compressed game session + let compressed_game_session = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_game_session.address, + Some(game_compressed_address) + ); + assert!(compressed_game_session.data.is_some()); + + let game_buf = compressed_game_session.data.unwrap().data; + let game_session = GameSession::deserialize(&mut &game_buf[..]).unwrap(); + assert_eq!(game_session.session_id, session_id); + assert_eq!(game_session.game_type, "Complex Game"); + assert_eq!(game_session.player, payer.pubkey()); + assert_eq!(game_session.score, 0); + assert!(game_session.compression_info.is_none()); + + // Verify CToken account + let spl_mint = find_spl_mint_address(&mint_signer_pubkey).0; + let (_, token_account_address) = + csdk_anchor_full_derived_test::get_ctokensigner_seeds(&payer.pubkey(), &spl_mint); + + let ctoken_accounts = rpc + .get_compressed_token_accounts_by_owner(&token_account_address, None, None) + .await + .unwrap() + .value; + + assert!( + !ctoken_accounts.items.is_empty(), + "Should have compressed token accounts" + ); +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_user_record_and_game_session( + rpc: &mut LightProgramTest, + user: &Keypair, + program_id: &Pubkey, + config_pda: &Pubkey, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + authority: &Keypair, + mint_authority_keypair: &Keypair, + some_account: &Keypair, + session_id: u64, + category_id: u64, +) -> Pubkey { + let state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + let mut remaining_accounts = PackedAccounts::default(); + let system_config = SystemAccountMetaConfig::new_with_cpi_context( + *program_id, + state_tree_info.cpi_context.unwrap(), + ); + let _ = remaining_accounts.add_system_accounts_v2(system_config); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + let decimals = 6u8; + let mint_authority = mint_authority_keypair.pubkey(); + let freeze_authority = mint_authority; + let mint_signer = Keypair::new(); + let compressed_mint_address = + derive_compressed_mint_address(&mint_signer.pubkey(), &address_tree_pubkey); + + let (spl_mint, mint_bump) = find_spl_mint_address(&mint_signer.pubkey()); + let accounts = csdk_anchor_full_derived_test::accounts::CreateUserRecordAndGameSession { + user: user.pubkey(), + mint_signer: mint_signer.pubkey(), + user_record: *user_record_pda, + game_session: *game_session_pda, + authority: authority.pubkey(), + mint_authority, + some_account: some_account.pubkey(), + ctoken_program: C_TOKEN_PROGRAM_ID.into(), + compress_token_program_cpi_authority: light_compressed_token_types::CPI_AUTHORITY_PDA + .into(), + system_program: solana_sdk::system_program::ID, + config: *config_pda, + rent_sponsor: RENT_SPONSOR, + }; + + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![ + AddressWithTree { + address: user_compressed_address, + tree: address_tree_pubkey, + }, + AddressWithTree { + address: game_compressed_address, + tree: address_tree_pubkey, + }, + AddressWithTree { + address: compressed_mint_address, + tree: address_tree_pubkey, + }, + ], + None, + ) + .await + .unwrap() + .value; + + let user_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + let game_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + let _mint_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + + let packed_tree_infos = rpc_result.pack_tree_infos(&mut remaining_accounts); + + let user_address_tree_info = packed_tree_infos.address_trees[0]; + let game_address_tree_info = packed_tree_infos.address_trees[1]; + let mint_address_tree_info = packed_tree_infos.address_trees[2]; + + let (system_accounts, _, _) = remaining_accounts.to_account_metas(); + + let instruction_data = + csdk_anchor_full_derived_test::instruction::CreateUserRecordAndGameSession { + account_data: AccountCreationData { + // Instruction data fields (accounts come from ctx.accounts.*) + owner: user.pubkey(), + category_id, + user_name: "Complex User".to_string(), + session_id, + game_type: "Complex Game".to_string(), + placeholder_id: 0, + counter: 0, + mint_name: "Complex Token".to_string(), + mint_symbol: "CPLX".to_string(), + mint_uri: "https://example.com/complex.json".to_string(), + mint_decimals: 9, + mint_supply: 1_000_000_000, + mint_update_authority: Some(mint_authority), + mint_freeze_authority: Some(freeze_authority), + additional_metadata: None, + }, + compression_params: CompressionParams { + proof: rpc_result.proof, + user_compressed_address, + user_address_tree_info, + user_output_state_tree_index, + game_compressed_address, + game_address_tree_info, + game_output_state_tree_index, + mint_bump, + mint_with_context: CompressedMintWithContext { + leaf_index: 0, + prove_by_index: false, + root_index: mint_address_tree_info.root_index, + address: compressed_mint_address, + mint: CompressedMintInstructionData { + supply: 0, + decimals, + metadata: CompressedMintMetadata { + version: 3, + mint: spl_mint.into(), + spl_mint_initialized: false, + }, + mint_authority: Some(mint_authority.into()), + freeze_authority: Some(freeze_authority.into()), + extensions: None, + }, + }, + }, + }; + + let instruction = Instruction { + program_id: *program_id, + accounts: [accounts.to_account_metas(None), system_accounts].concat(), + data: instruction_data.data(), + }; + + let result = rpc + .create_and_send_transaction( + &[instruction], + &user.pubkey(), + &[user, &mint_signer, mint_authority_keypair, authority], + ) + .await; + + assert!( + result.is_ok(), + "Complex seed creation transaction should succeed: {:?}", + result + ); + + mint_signer.pubkey() +} diff --git a/sdk-tests/sdk-compressible-test/Cargo.toml b/sdk-tests/sdk-compressible-test/Cargo.toml index 1cc326d68b..2fe8eb94b1 100644 --- a/sdk-tests/sdk-compressible-test/Cargo.toml +++ b/sdk-tests/sdk-compressible-test/Cargo.toml @@ -22,6 +22,7 @@ light-sdk = { workspace = true, features = ["anchor", "idl-build", "v2", "anchor light-sdk-types = { workspace = true, features = ["v2", "cpi-context"] } light-hasher = { workspace = true, features = ["solana"] } solana-program = { workspace = true } +solana-system-interface = { workspace = true } light-macros = { workspace = true, features = ["solana"] } borsh = { workspace = true } light-compressed-account = { workspace = true, features = ["solana"] } @@ -34,7 +35,7 @@ light-compressible = { workspace = true, features = ["anchor"] } [dev-dependencies] light-token-client = { workspace = true } -light-program-test = { workspace = true, features = ["v2", "devenv"] } +light-program-test = { workspace = true, features = ["devenv"] } light-client = { workspace = true, features = ["v2"] } light-compressible-client = { workspace = true, features = ["anchor"] } light-test-utils = { workspace = true} diff --git a/sdk-tests/sdk-compressible-test/src/instruction_accounts.rs b/sdk-tests/sdk-compressible-test/src/instruction_accounts.rs index 560219358d..2013467bc6 100644 --- a/sdk-tests/sdk-compressible-test/src/instruction_accounts.rs +++ b/sdk-tests/sdk-compressible-test/src/instruction_accounts.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use light_sdk::compressible::OPTION_COMPRESSION_INFO_SPACE; /// CompressAccountsIdempotent, DecompressAccountsIdempotent, /// InitializeCompressionConfig, UpdateCompressionConfig accounts are all @@ -15,7 +16,7 @@ pub struct CreateRecord<'info> { // discriminator + owner + string len + name + score + // option. Note that in the onchain space // CompressionInfo is always Some. - space = 8 + 32 + 4 + 32 + 8 + 10, + space = 8 + 32 + 4 + 32 + 8 + OPTION_COMPRESSION_INFO_SPACE, seeds = [b"user_record", user.key().as_ref()], bump, )] @@ -40,7 +41,7 @@ pub struct CreatePlaceholderRecord<'info> { init, payer = user, // discriminator + compression_info + owner + string len + name + placeholder_id - space = 8 + 10 + 32 + 4 + 32 + 8, + space = 8 + OPTION_COMPRESSION_INFO_SPACE + 32 + 4 + 32 + 8, seeds = [b"placeholder_record", placeholder_id.to_le_bytes().as_ref()], bump, )] @@ -67,7 +68,7 @@ pub struct CreateUserRecordAndGameSession<'info> { // discriminator + owner + string len + name + score + // option. Note that in the onchain space // CompressionInfo is always Some. - space = 8 + 32 + 4 + 32 + 8 + 10, + space = 8 + 32 + 4 + 32 + 8 + OPTION_COMPRESSION_INFO_SPACE, seeds = [b"user_record", user.key().as_ref()], bump, )] @@ -77,7 +78,7 @@ pub struct CreateUserRecordAndGameSession<'info> { payer = user, // discriminator + option + session_id + player + // string len + game_type + start_time + end_time(Option) + score - space = 8 + 10 + 8 + 32 + 4 + 32 + 8 + 9 + 8, + space = 8 + OPTION_COMPRESSION_INFO_SPACE + 8 + 32 + 4 + 32 + 8 + 9 + 8, seeds = [b"game_session", account_data.session_id.to_le_bytes().as_ref()], bump, )] @@ -116,7 +117,7 @@ pub struct CreateGameSession<'info> { #[account( init, payer = player, - space = 8 + 9 + 8 + 32 + 4 + 32 + 8 + 9 + 8, // discriminator + compression_info + session_id + player + string len + game_type + start_time + end_time(Option) + score + space = 8 + 24 + 8 + 32 + 4 + 32 + 8 + 9 + 8, // discriminator + compression_info + session_id + player + string len + game_type + start_time + end_time(Option) + score seeds = [b"game_session", session_id.to_le_bytes().as_ref()], bump, )] @@ -142,6 +143,7 @@ pub struct UpdateRecord<'info> { constraint = user_record.owner == user.key() )] pub user_record: Account<'info, UserRecord>, + pub system_program: Program<'info, System>, } #[derive(Accounts)] @@ -156,6 +158,7 @@ pub struct UpdateGameSession<'info> { constraint = game_session.player == player.key() )] pub game_session: Account<'info, GameSession>, + pub system_program: Program<'info, System>, } #[derive(Accounts)] @@ -171,7 +174,6 @@ pub struct CompressAccountsIdempotent<'info> { pub rent_sponsor: AccountInfo<'info>, } -// TODO: split into one ix with ctoken and one without. #[derive(Accounts)] pub struct DecompressAccountsIdempotent<'info> { #[account(mut)] @@ -181,18 +183,16 @@ pub struct DecompressAccountsIdempotent<'info> { pub config: AccountInfo<'info>, /// UNCHECKED: Anyone can pay to init PDAs. #[account(mut)] - pub rent_payer: Signer<'info>, - /// CHECK: Checked in protocol. - #[account(mut)] - pub ctoken_rent_sponsor: UncheckedAccount<'info>, - /// CHECK: Checked in protocol. - pub ctoken_config: UncheckedAccount<'info>, - /// ctoken program (always required in mixed variant) - /// CHECK: Checked by Protocol. - pub ctoken_program: UncheckedAccount<'info>, - /// CPI authority PDA of the compressed token program (always required in mixed variant) - /// CHECK: Checked by Protocol. - pub ctoken_cpi_authority: UncheckedAccount<'info>, + pub rent_sponsor: AccountInfo<'info>, + /// CHECK: anyone can pay (optional - only needed if decompressing tokens) + #[account(mut)] + pub ctoken_rent_sponsor: Option>, + /// CHECK: checked by SDK + pub ctoken_config: Option>, + /// CHECK: + pub ctoken_program: Option>, + /// CHECK: + pub ctoken_cpi_authority: Option>, /// CHECK: unchecked. pub some_mint: UncheckedAccount<'info>, } diff --git a/sdk-tests/sdk-compressible-test/src/instructions/compress_accounts_idempotent.rs b/sdk-tests/sdk-compressible-test/src/instructions/compress_accounts_idempotent.rs index 1b2a77f7a0..4e8a41acdb 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/compress_accounts_idempotent.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/compress_accounts_idempotent.rs @@ -16,7 +16,6 @@ pub fn compress_accounts_idempotent<'info>( ctx: Context<'_, '_, 'info, 'info, CompressAccountsIdempotent<'info>>, proof: ValidityProof, compressed_accounts: Vec, - signer_seeds: Vec>>, system_accounts_offset: u8, ) -> Result<()> { let compression_config = CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; @@ -38,11 +37,12 @@ pub fn compress_accounts_idempotent<'info>( LIGHT_CPI_SIGNER, ); - let pda_accounts_start = ctx.remaining_accounts.len() - signer_seeds.len(); - let solana_accounts = &ctx.remaining_accounts[pda_accounts_start..]; + let system_accounts_end = cpi_accounts.system_accounts_end_offset(); + let solana_accounts = &cpi_accounts.to_account_infos()[system_accounts_end..]; let mut compressed_pda_infos = Vec::new(); let mut pda_indices_to_close: Vec = Vec::new(); + let mut compressed_account_idx = 0; for (i, account_info) in solana_accounts.iter().enumerate() { if account_info.data_is_empty() { @@ -52,7 +52,8 @@ pub fn compress_accounts_idempotent<'info>( if account_info.owner == &crate::ID { let data = account_info.try_borrow_data()?; let discriminator = &data[0..8]; - let meta = compressed_accounts[i]; + let meta = compressed_accounts[compressed_account_idx]; + compressed_account_idx += 1; // TODO: consider CHECKING seeds. match discriminator { @@ -68,7 +69,6 @@ pub fn compress_accounts_idempotent<'info>( &mut account_data, &meta, &cpi_accounts, - &compression_config.compression_delay, &compression_config.address_space, )?; @@ -87,7 +87,6 @@ pub fn compress_accounts_idempotent<'info>( &mut account_data, &meta, &cpi_accounts, - &compression_config.compression_delay, &compression_config.address_space, )?; @@ -107,7 +106,6 @@ pub fn compress_accounts_idempotent<'info>( &mut account_data, &meta, &cpi_accounts, - &compression_config.compression_delay, &compression_config.address_space, )?; diff --git a/sdk-tests/sdk-compressible-test/src/instructions/create_game_session.rs b/sdk-tests/sdk-compressible-test/src/instructions/create_game_session.rs index e6fcd68e13..c478821ecd 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/create_game_session.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/create_game_session.rs @@ -57,6 +57,7 @@ pub fn create_game_session<'info>( let compressed_info = prepare_compressed_account_on_init::( &game_session_info, game_session_data_mut, + &config, compressed_address, new_address_params, output_state_tree_index, diff --git a/sdk-tests/sdk-compressible-test/src/instructions/create_placeholder_record.rs b/sdk-tests/sdk-compressible-test/src/instructions/create_placeholder_record.rs index b8ee6fbe06..f3ee307cdd 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/create_placeholder_record.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/create_placeholder_record.rs @@ -49,6 +49,7 @@ pub fn create_placeholder_record<'info>( let compressed_info = prepare_compressed_account_on_init::( &placeholder_info, placeholder_data_mut, + &config, compressed_address, new_address_params, output_state_tree_index, diff --git a/sdk-tests/sdk-compressible-test/src/instructions/create_record.rs b/sdk-tests/sdk-compressible-test/src/instructions/create_record.rs index eb50dfc649..81d918c412 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/create_record.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/create_record.rs @@ -47,6 +47,7 @@ pub fn create_record<'info>( let compressed_info = prepare_compressed_account_on_init::( &user_record_info, user_record_data_mut, + &config, compressed_address, new_address_params, output_state_tree_index, 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 2d82dc0884..e6bff538f4 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 @@ -72,6 +72,7 @@ pub fn create_user_record_and_game_session<'info>( let user_compressed_info = prepare_compressed_account_on_init::( &user_record_info, user_record_data_mut, + &config, compression_params.user_compressed_address, user_new_address_params, compression_params.user_output_state_tree_index, @@ -88,6 +89,7 @@ pub fn create_user_record_and_game_session<'info>( let game_compressed_info = prepare_compressed_account_on_init::( &game_session_info, game_session_data_mut, + &config, compression_params.game_compressed_address, game_new_address_params, compression_params.game_output_state_tree_index, diff --git a/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs b/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs index de7bb34f5f..c3c2726c03 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs @@ -14,6 +14,7 @@ use light_sdk::{ }, }; use light_sdk_types::cpi_accounts::CpiAccountsConfig; +use solana_program::program_error::ProgramError; use crate::{constants::*, errors::ErrorCode, instruction_accounts::*, state::*, LIGHT_CPI_SIGNER}; pub fn decompress_accounts_idempotent<'info>( @@ -32,7 +33,7 @@ pub fn decompress_accounts_idempotent<'info>( i: usize, address_space: Pubkey, cpi_accounts: &CpiAccounts<'b, 'info>, - rent_payer: &Signer<'info>, + rent_sponsor: &AccountInfo<'info>, out: &mut Vec< light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo, >, @@ -48,7 +49,7 @@ pub fn decompress_accounts_idempotent<'info>( data, into_compressed_meta_with_address(meta, &solana_accounts[i], address_space, &crate::ID), &solana_accounts[i], - rent_payer, + rent_sponsor, cpi_accounts, seed_refs.as_slice(), ) @@ -66,7 +67,7 @@ pub fn decompress_accounts_idempotent<'info>( i: usize, address_space: Pubkey, cpi_accounts: &CpiAccounts<'b, 'info>, - rent_payer: &Signer<'info>, + rent_sponsor: &AccountInfo<'info>, out: &mut Vec< light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo, >, @@ -83,7 +84,7 @@ pub fn decompress_accounts_idempotent<'info>( data, into_compressed_meta_with_address(meta, &solana_accounts[i], address_space, &crate::ID), &solana_accounts[i], - rent_payer, + rent_sponsor, cpi_accounts, seed_refs.as_slice(), ) @@ -101,7 +102,7 @@ pub fn decompress_accounts_idempotent<'info>( i: usize, address_space: Pubkey, cpi_accounts: &CpiAccounts<'b, 'info>, - rent_payer: &Signer<'info>, + rent_sponsor: &AccountInfo<'info>, out: &mut Vec< light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo, >, @@ -118,7 +119,7 @@ pub fn decompress_accounts_idempotent<'info>( data, into_compressed_meta_with_address(meta, &solana_accounts[i], address_space, &crate::ID), &solana_accounts[i], - rent_payer, + rent_sponsor, cpi_accounts, seed_refs.as_slice(), ) @@ -333,7 +334,7 @@ pub fn decompress_accounts_idempotent<'info>( i, address_space, &cpi_accounts, - &ctx.accounts.rent_payer, + &ctx.accounts.rent_sponsor, &mut compressed_pda_infos, )?; } @@ -345,7 +346,7 @@ pub fn decompress_accounts_idempotent<'info>( i, address_space, &cpi_accounts, - &ctx.accounts.rent_payer, + &ctx.accounts.rent_sponsor, &mut compressed_pda_infos, )?; } @@ -357,7 +358,7 @@ pub fn decompress_accounts_idempotent<'info>( i, address_space, &cpi_accounts, - &ctx.accounts.rent_payer, + &ctx.accounts.rent_sponsor, &mut compressed_pda_infos, )?; } @@ -406,14 +407,35 @@ pub fn decompress_accounts_idempotent<'info>( // init tokens. if has_tokens { + let ctoken_program = ctx + .accounts + .ctoken_program + .as_ref() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let ctoken_rent_sponsor = ctx + .accounts + .ctoken_rent_sponsor + .as_ref() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let ctoken_cpi_authority = ctx + .accounts + .ctoken_cpi_authority + .as_ref() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let ctoken_config = ctx + .accounts + .ctoken_config + .as_ref() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + process_tokens( ctx.accounts, ctx.remaining_accounts, fee_payer, - &ctx.accounts.ctoken_program, - &ctx.accounts.ctoken_rent_sponsor, - &ctx.accounts.ctoken_cpi_authority, - &ctx.accounts.ctoken_config, + ctoken_program, + ctoken_rent_sponsor, + ctoken_cpi_authority, + ctoken_config, &ctx.accounts.config, ctoken_accounts, proof, diff --git a/sdk-tests/sdk-compressible-test/src/instructions/initialize_compression_config.rs b/sdk-tests/sdk-compressible-test/src/instructions/initialize_compression_config.rs index ec1f8f046e..9be151ef7c 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/initialize_compression_config.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/initialize_compression_config.rs @@ -7,17 +7,24 @@ use crate::instruction_accounts::*; pub fn initialize_compression_config( ctx: Context, - compression_delay: u32, rent_sponsor: Pubkey, address_space: Vec, ) -> Result<()> { + // For tests, set compression_authority to the program's authority (can be a PDA in real apps) + let compression_authority = ctx.accounts.authority.key(); + // Use default rent config for tests + let rent_config = light_compressible::rent::RentConfig::default(); + // Default write_top_up for tests + let write_top_up: u32 = 5_000; process_initialize_compression_config_checked( &ctx.accounts.config.to_account_info(), &ctx.accounts.authority.to_account_info(), &ctx.accounts.program_data.to_account_info(), &rent_sponsor, + &compression_authority, + rent_config, + write_top_up, address_space, - compression_delay, 0, // one global config for now, so bump is 0. &ctx.accounts.payer.to_account_info(), &ctx.accounts.system_program.to_account_info(), diff --git a/sdk-tests/sdk-compressible-test/src/instructions/update_compression_config.rs b/sdk-tests/sdk-compressible-test/src/instructions/update_compression_config.rs index c0e96f83f2..5b505ab161 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/update_compression_config.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/update_compression_config.rs @@ -6,8 +6,10 @@ use crate::instruction_accounts::*; pub fn update_compression_config( ctx: Context, - new_compression_delay: Option, new_rent_sponsor: Option, + new_compression_authority: Option, + new_rent_config: Option, + new_write_top_up: Option, new_address_space: Option>, new_update_authority: Option, ) -> Result<()> { @@ -16,8 +18,10 @@ pub fn update_compression_config( &ctx.accounts.authority.to_account_info(), new_update_authority.as_ref(), new_rent_sponsor.as_ref(), + new_compression_authority.as_ref(), + new_rent_config, + new_write_top_up, new_address_space, - new_compression_delay, &crate::ID, )?; diff --git a/sdk-tests/sdk-compressible-test/src/instructions/update_game_session.rs b/sdk-tests/sdk-compressible-test/src/instructions/update_game_session.rs index 25b45b4528..0b831ee0a9 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/update_game_session.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/update_game_session.rs @@ -12,10 +12,12 @@ pub fn update_game_session( game_session.score = new_score; game_session.end_time = Some(Clock::get()?.unix_timestamp as u64); - // Must manually set compression info - game_session - .compression_info_mut() - .bump_last_written_slot()?; + // Rent top-up on write using the abstracted method + game_session.compression_info().top_up_rent( + &game_session.to_account_info(), + &ctx.accounts.player.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + )?; Ok(()) } diff --git a/sdk-tests/sdk-compressible-test/src/instructions/update_record.rs b/sdk-tests/sdk-compressible-test/src/instructions/update_record.rs index 6b8c7cb618..8f76d4282d 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/update_record.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/update_record.rs @@ -9,10 +9,11 @@ pub fn update_record(ctx: Context, name: String, score: u64) -> Re user_record.name = name; user_record.score = score; - // 1. Must manually set compression info - user_record - .compression_info_mut() - .bump_last_written_slot()?; + user_record.compression_info().top_up_rent( + &user_record.to_account_info(), + &ctx.accounts.user.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + )?; Ok(()) } diff --git a/sdk-tests/sdk-compressible-test/src/lib.rs b/sdk-tests/sdk-compressible-test/src/lib.rs index 5e160c4931..cae42204e0 100644 --- a/sdk-tests/sdk-compressible-test/src/lib.rs +++ b/sdk-tests/sdk-compressible-test/src/lib.rs @@ -94,13 +94,11 @@ pub mod sdk_compressible_test { pub fn initialize_compression_config( ctx: Context, - compression_delay: u32, rent_sponsor: Pubkey, address_space: Vec, ) -> Result<()> { instructions::initialize_compression_config::initialize_compression_config( ctx, - compression_delay, rent_sponsor, address_space, ) @@ -148,29 +146,31 @@ pub mod sdk_compressible_test { ctx: Context<'_, '_, 'info, 'info, CompressAccountsIdempotent<'info>>, proof: ValidityProof, compressed_accounts: Vec, - signer_seeds: Vec>>, system_accounts_offset: u8, ) -> Result<()> { instructions::compress_accounts_idempotent::compress_accounts_idempotent( ctx, proof, compressed_accounts, - signer_seeds, system_accounts_offset, ) } pub fn update_compression_config( ctx: Context, - new_compression_delay: Option, new_rent_sponsor: Option, + new_compression_authority: Option, + new_rent_config: Option, + new_write_top_up: Option, new_address_space: Option>, new_update_authority: Option, ) -> Result<()> { instructions::update_compression_config::update_compression_config( ctx, - new_compression_delay, new_rent_sponsor, + new_compression_authority, + new_rent_config, + new_write_top_up, new_address_space, new_update_authority, ) diff --git a/sdk-tests/sdk-compressible-test/tests/game_session_tests.rs b/sdk-tests/sdk-compressible-test/tests/game_session_tests.rs index 5f19878457..e8c79b1a81 100644 --- a/sdk-tests/sdk-compressible-test/tests/game_session_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/game_session_tests.rs @@ -1,7 +1,6 @@ use anchor_lang::{AccountDeserialize, AnchorDeserialize, Discriminator, ToAccountMetas}; use light_compressed_account::address::derive_address; -use light_compressed_token_sdk::ctoken; -use light_compressible_client::CompressibleInstruction; +use light_compressible_client::compressible_instruction; use light_program_test::{ program_test::{ initialize_compression_config, setup_mock_program_data, LightProgramTest, TestRpc, @@ -33,10 +32,9 @@ async fn test_custom_compression_game_session() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; @@ -122,12 +120,10 @@ pub async fn decompress_single_game_session( .unwrap() .value; - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); - let instruction = - light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + light_compressible_client::compressible_instruction::decompress_accounts_idempotent( program_id, - &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &compressible_instruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, &[*game_session_pda], &[( c_game_pda, @@ -136,16 +132,15 @@ pub async fn decompress_single_game_session( &sdk_compressible_test::accounts::DecompressAccountsIdempotent { fee_payer: payer.pubkey(), config: CompressibleConfig::derive_pda(program_id, 0).0, - rent_payer: payer.pubkey(), - ctoken_rent_sponsor: ctoken::rent_sponsor_pda(), - ctoken_config: ctoken::config_pda(), - ctoken_program: ctoken::id(), - ctoken_cpi_authority: ctoken::cpi_authority(), + rent_sponsor: payer.pubkey(), + ctoken_rent_sponsor: None, + ctoken_config: None, + ctoken_program: None, + ctoken_cpi_authority: None, some_mint: payer.pubkey(), } .to_account_metas(None), rpc_result, - output_state_tree_info, ) .unwrap(); @@ -183,7 +178,7 @@ pub async fn decompress_single_game_session( .compression_info .as_ref() .unwrap() - .last_written_slot(), + .last_claimed_slot(), expected_slot ); } diff --git a/sdk-tests/sdk-compressible-test/tests/helpers.rs b/sdk-tests/sdk-compressible-test/tests/helpers.rs index 847da9ddaf..61c1376c49 100644 --- a/sdk-tests/sdk-compressible-test/tests/helpers.rs +++ b/sdk-tests/sdk-compressible-test/tests/helpers.rs @@ -5,8 +5,7 @@ use anchor_lang::{ AccountDeserialize, AnchorDeserialize, Discriminator, InstructionData, ToAccountMetas, }; use light_compressed_account::address::derive_address; -use light_compressed_token_sdk::ctoken; -use light_compressible_client::CompressibleInstruction; +use light_compressible_client::compressible_instruction; use light_macros::pubkey; use light_program_test::{program_test::LightProgramTest, AddressWithTree, Indexer, Rpc}; use light_sdk::{ @@ -156,11 +155,10 @@ pub async fn decompress_single_user_record( .unwrap() .value; - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); let instruction = - light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + light_compressible_client::compressible_instruction::decompress_accounts_idempotent( program_id, - &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &compressible_instruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, &[*user_record_pda], &[( c_user_pda, @@ -169,16 +167,15 @@ pub async fn decompress_single_user_record( &sdk_compressible_test::accounts::DecompressAccountsIdempotent { fee_payer: payer.pubkey(), config: CompressibleConfig::derive_pda(program_id, 0).0, - rent_payer: payer.pubkey(), - ctoken_rent_sponsor: ctoken::rent_sponsor_pda(), - ctoken_config: ctoken::config_pda(), - ctoken_program: ctoken::id(), - ctoken_cpi_authority: ctoken::cpi_authority(), + rent_sponsor: payer.pubkey(), + ctoken_rent_sponsor: None, + ctoken_config: None, + ctoken_program: None, + ctoken_cpi_authority: None, some_mint: payer.pubkey(), } .to_account_metas(None), rpc_result, - output_state_tree_info, ) .unwrap(); @@ -230,7 +227,7 @@ pub async fn decompress_single_user_record( .compression_info .as_ref() .unwrap() - .last_written_slot(), + .last_claimed_slot(), expected_slot ); } diff --git a/sdk-tests/sdk-compressible-test/tests/idempotency_tests.rs b/sdk-tests/sdk-compressible-test/tests/idempotency_tests.rs index 6ce75929c1..4638e960db 100644 --- a/sdk-tests/sdk-compressible-test/tests/idempotency_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/idempotency_tests.rs @@ -1,7 +1,6 @@ use anchor_lang::{AccountDeserialize, AnchorDeserialize, ToAccountMetas}; use light_compressed_account::address::derive_address; -use light_compressed_token_sdk::ctoken; -use light_compressible_client::CompressibleInstruction; +use light_compressible_client::compressible_instruction; use light_program_test::{ program_test::{ initialize_compression_config, setup_mock_program_data, LightProgramTest, TestRpc, @@ -30,10 +29,9 @@ async fn test_double_decompression_attack() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; @@ -90,12 +88,10 @@ async fn test_double_decompression_attack() { .unwrap() .value; - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); - let instruction = - light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + light_compressible_client::compressible_instruction::decompress_accounts_idempotent( &program_id, - &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &compressible_instruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, &[user_record_pda], &[( c_user_pda, @@ -104,16 +100,15 @@ async fn test_double_decompression_attack() { &sdk_compressible_test::accounts::DecompressAccountsIdempotent { fee_payer: payer.pubkey(), config: CompressibleConfig::derive_pda(&program_id, 0).0, - rent_payer: payer.pubkey(), - ctoken_rent_sponsor: ctoken::rent_sponsor_pda(), - ctoken_config: ctoken::config_pda(), - ctoken_program: ctoken::id(), - ctoken_cpi_authority: ctoken::cpi_authority(), + rent_sponsor: payer.pubkey(), + ctoken_rent_sponsor: None, + ctoken_config: None, + ctoken_program: None, + ctoken_cpi_authority: None, some_mint: payer.pubkey(), } .to_account_metas(None), rpc_result, - output_state_tree_info, ) .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 291992b3f1..cf881fe4c1 100644 --- a/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/multi_account_tests.rs @@ -9,7 +9,7 @@ use light_compressed_token_sdk::{ pack::compat::CTokenDataWithVariant, }; use light_compressed_token_types::CPI_AUTHORITY_PDA; -use light_compressible_client::CompressibleInstruction; +use light_compressible_client::compressible_instruction; use light_ctoken_types::{ instructions::mint_action::{CompressedMintInstructionData, CompressedMintWithContext}, state::CompressedMintMetadata, @@ -61,10 +61,9 @@ async fn test_create_and_decompress_two_accounts() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![crate::helpers::ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; @@ -560,13 +559,11 @@ pub async fn decompress_multiple_pdas_with_ctoken( .unwrap() .value; - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); - let ctoken_config = ctoken::config_pda(); let instruction = - light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + light_compressible_client::compressible_instruction::decompress_accounts_idempotent( program_id, - &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &compressible_instruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, &[ *user_record_pda, *game_session_pda, @@ -638,16 +635,15 @@ pub async fn decompress_multiple_pdas_with_ctoken( &sdk_compressible_test::accounts::DecompressAccountsIdempotent { fee_payer: payer.pubkey(), config: CompressibleConfig::derive_pda(program_id, 0).0, - rent_payer: payer.pubkey(), - ctoken_rent_sponsor: ctoken::rent_sponsor_pda(), - ctoken_config, - ctoken_program: ctoken::id(), - ctoken_cpi_authority: ctoken::cpi_authority(), + rent_sponsor: payer.pubkey(), + ctoken_rent_sponsor: Some(ctoken::rent_sponsor_pda()), + ctoken_config: Some(ctoken_config), + ctoken_program: Some(ctoken::id()), + ctoken_cpi_authority: Some(ctoken::cpi_authority()), some_mint: ctoken_account.token.mint, } .to_account_metas(None), rpc_result, - output_state_tree_info, ) .unwrap(); @@ -697,7 +693,7 @@ pub async fn decompress_multiple_pdas_with_ctoken( .compression_info .as_ref() .unwrap() - .last_written_slot(), + .last_claimed_slot(), expected_slot ); @@ -730,7 +726,7 @@ pub async fn decompress_multiple_pdas_with_ctoken( .compression_info .as_ref() .unwrap() - .last_written_slot(), + .last_claimed_slot(), expected_slot ); @@ -835,12 +831,10 @@ pub async fn decompress_multiple_pdas( .unwrap() .value; - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); - let instruction = - light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + light_compressible_client::compressible_instruction::decompress_accounts_idempotent( program_id, - &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &compressible_instruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, &[*user_record_pda, *game_session_pda], &[ ( @@ -855,16 +849,15 @@ pub async fn decompress_multiple_pdas( &sdk_compressible_test::accounts::DecompressAccountsIdempotent { fee_payer: payer.pubkey(), config: CompressibleConfig::derive_pda(program_id, 0).0, - rent_payer: payer.pubkey(), - ctoken_rent_sponsor: ctoken::rent_sponsor_pda(), - ctoken_config: ctoken::config_pda(), - ctoken_program: ctoken::id(), - ctoken_cpi_authority: ctoken::cpi_authority(), + rent_sponsor: payer.pubkey(), + ctoken_rent_sponsor: None, + ctoken_config: None, + ctoken_program: None, + ctoken_cpi_authority: None, some_mint: payer.pubkey(), } .to_account_metas(None), rpc_result, - output_state_tree_info, ) .unwrap(); @@ -914,7 +907,7 @@ pub async fn decompress_multiple_pdas( .compression_info .as_ref() .unwrap() - .last_written_slot(), + .last_claimed_slot(), expected_slot ); @@ -947,7 +940,7 @@ pub async fn decompress_multiple_pdas( .compression_info .as_ref() .unwrap() - .last_written_slot(), + .last_claimed_slot(), expected_slot ); @@ -998,9 +991,9 @@ pub async fn compress_token_account_after_decompress( "Token account should have data before compression" ); - let (user_record_seeds, user_record_pubkey) = + let (_user_record_seeds, user_record_pubkey) = sdk_compressible_test::get_userrecord_seeds(&user.pubkey()); - let (game_session_seeds, game_session_pubkey) = + let (_game_session_seeds, game_session_pubkey) = sdk_compressible_test::get_gamesession_seeds(session_id); let (_, token_account_address) = get_ctoken_signer_seeds(&user.pubkey(), &mint); @@ -1099,9 +1092,8 @@ pub async fn compress_token_account_after_decompress( .unwrap() .value; - let random_tree_info = rpc.get_random_state_tree_info().unwrap(); let instruction = - light_compressible_client::CompressibleInstruction::compress_accounts_idempotent( + light_compressible_client::compressible_instruction::compress_accounts_idempotent( program_id, sdk_compressible_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, &[user_record_pubkey, game_session_pubkey], @@ -1112,9 +1104,7 @@ pub async fn compress_token_account_after_decompress( rent_sponsor: RENT_SPONSOR, } .to_account_metas(None), - vec![user_record_seeds, game_session_seeds], proof_with_context, - random_tree_info, ) .unwrap(); @@ -1331,10 +1321,9 @@ async fn test_create_and_decompress_accounts_with_different_state_trees() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; diff --git a/sdk-tests/sdk-compressible-test/tests/placeholder_tests.rs b/sdk-tests/sdk-compressible-test/tests/placeholder_tests.rs index c4c07f8900..cb8adc9d87 100644 --- a/sdk-tests/sdk-compressible-test/tests/placeholder_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/placeholder_tests.rs @@ -1,6 +1,6 @@ use anchor_lang::{AccountDeserialize, Discriminator, InstructionData, ToAccountMetas}; use light_compressed_account::address::derive_address; -use light_compressible_client::CompressibleInstruction; +use light_compressible_client::compressible_instruction; use light_program_test::{ program_test::{ initialize_compression_config, setup_mock_program_data, LightProgramTest, TestRpc, @@ -38,10 +38,9 @@ async fn test_create_empty_compressed_account() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; @@ -150,10 +149,9 @@ async fn test_double_compression_attack() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; @@ -416,17 +414,16 @@ pub async fn compress_placeholder_record( .unwrap() .value; - let placeholder_seeds = sdk_compressible_test::get_placeholderrecord_seeds(placeholder_id); + let _placeholder_seeds = sdk_compressible_test::get_placeholderrecord_seeds(placeholder_id); let account = rpc .get_account(*placeholder_record_pda) .await .unwrap() .unwrap(); - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); let instruction = - light_compressible_client::CompressibleInstruction::compress_accounts_idempotent( + light_compressible_client::compressible_instruction::compress_accounts_idempotent( program_id, sdk_compressible_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, &[*placeholder_record_pda], @@ -437,9 +434,7 @@ pub async fn compress_placeholder_record( rent_sponsor: RENT_SPONSOR, } .to_account_metas(None), - vec![placeholder_seeds.0], rpc_result, - output_state_tree_info, ) .unwrap(); @@ -504,9 +499,7 @@ pub async fn compress_placeholder_record_for_double_test( .unwrap() .value; - let placeholder_seeds = sdk_compressible_test::get_placeholderrecord_seeds(placeholder_id); - - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + let _placeholder_seeds = sdk_compressible_test::get_placeholderrecord_seeds(placeholder_id); let accounts_to_compress = if let Some(account) = previous_account { vec![account] @@ -514,7 +507,7 @@ pub async fn compress_placeholder_record_for_double_test( panic!("Previous account should be provided"); }; let instruction = - light_compressible_client::CompressibleInstruction::compress_accounts_idempotent( + light_compressible_client::compressible_instruction::compress_accounts_idempotent( program_id, sdk_compressible_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, &[*placeholder_record_pda], @@ -525,9 +518,7 @@ pub async fn compress_placeholder_record_for_double_test( rent_sponsor: RENT_SPONSOR, } .to_account_metas(None), - vec![placeholder_seeds.0], rpc_result, - output_state_tree_info, ) .unwrap(); diff --git a/sdk-tests/sdk-compressible-test/tests/user_record_tests.rs b/sdk-tests/sdk-compressible-test/tests/user_record_tests.rs index 857b77568c..3dab2f194c 100644 --- a/sdk-tests/sdk-compressible-test/tests/user_record_tests.rs +++ b/sdk-tests/sdk-compressible-test/tests/user_record_tests.rs @@ -2,7 +2,8 @@ use anchor_lang::{ AccountDeserialize, AnchorDeserialize, Discriminator, InstructionData, ToAccountMetas, }; use light_compressed_account::address::derive_address; -use light_compressible_client::CompressibleInstruction; +use light_compressible::rent::{RentConfig, SLOTS_PER_EPOCH}; +use light_compressible_client::compressible_instruction; use light_program_test::{ program_test::{ initialize_compression_config, setup_mock_program_data, LightProgramTest, TestRpc, @@ -18,6 +19,7 @@ use solana_instruction::Instruction; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signer::Signer; +use solana_system_interface::instruction as system_instruction; mod helpers; use helpers::{create_record, decompress_single_user_record, ADDRESS_SPACE, RENT_SPONSOR}; @@ -38,10 +40,9 @@ async fn test_create_decompress_compress_single_account() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; @@ -65,21 +66,37 @@ async fn test_create_decompress_compress_single_account() { ) .await; - rpc.warp_to_slot(101).unwrap(); + // Top up PDA so it's initially NOT compressible (sufficiently funded) + // Fund exactly one epoch of rent plus compression_cost, so after one epoch passes it becomes compressible. + let pda_account = rpc.get_account(user_record_pda).await.unwrap().unwrap(); + let bytes = pda_account.data.len() as u64; + let rent_cfg = RentConfig::default(); + let rent_per_epoch = rent_cfg.rent_curve_per_epoch(bytes); + let compression_cost = rent_cfg.compression_cost as u64; + let top_up = rent_per_epoch + compression_cost; + + let transfer_ix = system_instruction::transfer(&payer.pubkey(), &user_record_pda, top_up); + let res = rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await; + assert!(res.is_ok(), "Top-up transfer should succeed"); + // Immediately try to compress – should FAIL because not compressible yet (sufficiently funded) let result = compress_record(&mut rpc, &payer, &program_id, &user_record_pda, true).await; - assert!(result.is_err(), "Compression should fail due to slot delay"); - if let Err(err) = result { - let err_msg = format!("{:?}", err); - assert!( - err_msg.contains("Custom(16001)"), - "Expected error message about slot delay, got: {}", - err_msg - ); - } - rpc.warp_to_slot(200).unwrap(); + assert!( + result.is_err(), + "Compression should fail while sufficiently funded" + ); + + // Advance one full epoch so required_epochs increases and the account becomes compressible + rpc.warp_to_slot(SLOTS_PER_EPOCH * 2).unwrap(); + + // Now compression should SUCCEED (account no longer sufficiently funded for current+next epoch) let result = compress_record(&mut rpc, &payer, &program_id, &user_record_pda, false).await; - assert!(result.is_ok(), "Compression should succeed"); + assert!( + result.is_ok(), + "Compression should succeed after epochs advance" + ); } #[tokio::test] @@ -96,10 +113,9 @@ async fn test_update_record_compression_info() { &payer, &program_id, &payer, - 100, RENT_SPONSOR, vec![ADDRESS_SPACE[0]], - &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + &compressible_instruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, None, ) .await; @@ -127,6 +143,7 @@ async fn test_update_record_compression_info() { let accounts = sdk_compressible_test::accounts::UpdateRecord { user: payer.pubkey(), user_record: user_record_pda, + system_program: solana_sdk::system_program::id(), }; let instruction_data = sdk_compressible_test::instruction::UpdateRecord { @@ -165,8 +182,8 @@ async fn test_update_record_compression_info() { .compression_info .as_ref() .unwrap() - .last_written_slot(), - 150 + .last_claimed_slot(), + 100 ); assert!(!updated_user_record .compression_info @@ -223,9 +240,7 @@ pub async fn compress_record( .unwrap() .value; - let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); - - let instruction = CompressibleInstruction::compress_accounts_idempotent( + let instruction = compressible_instruction::compress_accounts_idempotent( program_id, sdk_compressible_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, &[*user_record_pda], @@ -236,9 +251,7 @@ pub async fn compress_record( rent_sponsor: RENT_SPONSOR, } .to_account_metas(None), - vec![sdk_compressible_test::get_userrecord_seeds(&payer.pubkey()).0], rpc_result, - output_state_tree_info, ) .unwrap();