diff --git a/Cargo.lock b/Cargo.lock index 8531f685da..b8ae90aa66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "anchor-spl" version = "0.31.1" -source = "git+https://github.com/lightprotocol/anchor?rev=4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3#4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3" +source = "git+https://github.com/lightprotocol/anchor?rev=da005d7f#da005d7f1f977d5220eaa65da26cdae2df0fe25e" dependencies = [ "anchor-lang", "mpl-token-metadata", @@ -1638,7 +1638,7 @@ 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=4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3)", + "anchor-spl 0.31.1 (git+https://github.com/lightprotocol/anchor?rev=da005d7f)", "bincode", "borsh 0.10.4", "light-client", @@ -1646,6 +1646,7 @@ dependencies = [ "light-compressible", "light-compressible-client", "light-hasher", + "light-heap", "light-macros", "light-program-test", "light-sdk", @@ -1661,6 +1662,7 @@ dependencies = [ "solana-instruction", "solana-keypair", "solana-logger", + "solana-msg 2.2.1", "solana-program", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", @@ -4007,6 +4009,7 @@ dependencies = [ "light-compressible", "light-concurrent-merkle-tree", "light-hasher", + "light-heap", "light-macros", "light-sdk-macros", "light-sdk-types", @@ -4244,6 +4247,7 @@ dependencies = [ "arrayvec", "borsh 0.10.4", "light-account-checks", + "light-batched-merkle-tree", "light-compressed-account", "light-compressed-token", "light-compressible", @@ -6114,6 +6118,8 @@ dependencies = [ "light-token-types", "light-zero-copy", "serial_test", + "solana-account-info", + "solana-pubkey 2.4.0", "solana-sdk", "tokio", ] diff --git a/program-libs/compressible/src/lib.rs b/program-libs/compressible/src/lib.rs index dc16a63c3d..1edbcaaddd 100644 --- a/program-libs/compressible/src/lib.rs +++ b/program-libs/compressible/src/lib.rs @@ -22,4 +22,7 @@ pub struct CreateAccountsProof { pub address_tree_info: PackedAddressTreeInfo, /// Output state tree index for new compressed accounts. pub output_state_tree_index: u8, + /// State merkle tree index (needed for mint creation decompress validation). + /// This is optional to maintain backwards compatibility. + pub state_tree_index: Option, } diff --git a/programs/system/src/cpi_context/state.rs b/programs/system/src/cpi_context/state.rs index d9acfa94f6..0123216878 100644 --- a/programs/system/src/cpi_context/state.rs +++ b/programs/system/src/cpi_context/state.rs @@ -131,17 +131,14 @@ impl<'a> ZCpiContextAccount2<'a> { &'a mut self, instruction_data: &WrappedInstructionData<'b, T>, ) -> Result<(), SystemProgramError> { - let pre_address_len = self.new_addresses.len(); // Cache owner bytes to avoid repeated calls let owner_bytes = instruction_data.owner().to_bytes(); // Store new addresses for address in instruction_data.new_addresses() { let assigned_index = address.assigned_compressed_account_index(); - // Use checked arithmetic to prevent overflow - let assigned_account_index = (assigned_index.unwrap_or(0) as u8) - .checked_add(pre_address_len as u8) - .ok_or(ZeroCopyError::Size)?; + // Use the assigned index directly - caller provides absolute index + let assigned_account_index = assigned_index.unwrap_or(0) as u8; let new_address = CpiContextNewAddressParamsAssignedPacked { owner: owner_bytes, // Use cached owner bytes seed: address.seed(), diff --git a/sdk-libs/compressible-client/src/create_accounts_proof.rs b/sdk-libs/compressible-client/src/create_accounts_proof.rs index 7019a7871d..4761ac676a 100644 --- a/sdk-libs/compressible-client/src/create_accounts_proof.rs +++ b/sdk-libs/compressible-client/src/create_accounts_proof.rs @@ -7,15 +7,17 @@ //! - Returns a single `address_tree_info` since all accounts use the same tree use light_client::{ - indexer::{AddressWithTree, Indexer, IndexerError}, + indexer::{AddressWithTree, Indexer, IndexerError, ValidityProofWithContext}, rpc::{Rpc, RpcError}, }; +use light_compressed_account::instruction_data::compressed_proof::ValidityProof; +use light_sdk::instruction::PackedAddressTreeInfo; use light_token_sdk::compressed_token::create_compressed_mint::derive_mint_compressed_address; use solana_instruction::AccountMeta; use solana_pubkey::Pubkey; use thiserror::Error; -use crate::pack::{pack_proof, PackError}; +use crate::pack::{pack_proof, pack_proof_for_mints, PackError}; /// Error type for create accounts proof operations. #[derive(Debug, Error)] @@ -136,7 +138,28 @@ pub async fn get_create_accounts_proof( inputs: Vec, ) -> Result { if inputs.is_empty() { - return Err(CreateAccountsProofError::EmptyInputs); + // Token-only instructions: no addresses to derive, but still need tree info + let state_tree_info = rpc + .get_random_state_tree_info() + .map_err(CreateAccountsProofError::Rpc)?; + + // Pack system accounts with empty proof + let packed = pack_proof( + program_id, + ValidityProofWithContext::default(), + &state_tree_info, + None, // No CPI context needed for token-only + )?; + + return Ok(CreateAccountsProofResult { + create_accounts_proof: CreateAccountsProof { + proof: ValidityProof::default(), + address_tree_info: PackedAddressTreeInfo::default(), + output_state_tree_index: packed.output_tree_index, + state_tree_index: None, + }, + remaining_accounts: packed.remaining_accounts, + }); } // 1. Get address tree (opinionated: always V2) @@ -169,7 +192,7 @@ pub async fn get_create_accounts_proof( .get_random_state_tree_info() .map_err(CreateAccountsProofError::Rpc)?; - // 6. Determine CPI context + // 6. Determine CPI context and whether we have mints // For INIT with mints: need CPI context for cross-program invocation let has_mints = inputs .iter() @@ -180,13 +203,22 @@ pub async fn get_create_accounts_proof( None }; - // 7. Pack proof - let packed = pack_proof( - program_id, - validity_proof.clone(), - &state_tree_info, - cpi_context, - )?; + // 7. Pack proof (use mint-aware packing if mints are present) + let packed = if has_mints { + pack_proof_for_mints( + program_id, + validity_proof.clone(), + &state_tree_info, + cpi_context, + )? + } else { + pack_proof( + program_id, + validity_proof.clone(), + &state_tree_info, + cpi_context, + )? + }; // All addresses use the same tree, so just take the first packed info let address_tree_info = packed @@ -201,6 +233,7 @@ pub async fn get_create_accounts_proof( proof: validity_proof.proof, address_tree_info, output_state_tree_index: packed.output_tree_index, + state_tree_index: packed.state_tree_index, }, remaining_accounts: packed.remaining_accounts, }) diff --git a/sdk-libs/compressible-client/src/lib.rs b/sdk-libs/compressible-client/src/lib.rs index 9e09986cc3..4828a9ee8a 100644 --- a/sdk-libs/compressible-client/src/lib.rs +++ b/sdk-libs/compressible-client/src/lib.rs @@ -269,6 +269,8 @@ pub mod compressible_instruction { } /// Returns program account metas for PDA-only decompression (no CToken accounts). + /// Note: Still passes all 7 accounts because the struct has Optional fields that + /// Anchor still deserializes. Uses rent_sponsor as placeholder for ctoken_rent_sponsor. pub fn accounts_pda_only( fee_payer: Pubkey, config: Pubkey, @@ -278,6 +280,11 @@ pub mod compressible_instruction { AccountMeta::new(fee_payer, true), AccountMeta::new_readonly(config, false), AccountMeta::new(rent_sponsor, false), + // Optional token accounts - use placeholders that satisfy constraints + AccountMeta::new(rent_sponsor, false), // ctoken_rent_sponsor (mut) - reuse rent_sponsor + AccountMeta::new_readonly(LIGHT_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(LIGHT_TOKEN_CPI_AUTHORITY, false), + AccountMeta::new_readonly(COMPRESSIBLE_CONFIG_V1, false), ] } } diff --git a/sdk-libs/compressible-client/src/pack.rs b/sdk-libs/compressible-client/src/pack.rs index 180a28462e..d212d61f7f 100644 --- a/sdk-libs/compressible-client/src/pack.rs +++ b/sdk-libs/compressible-client/src/pack.rs @@ -56,6 +56,8 @@ pub struct PackedProofResult { pub packed_tree_infos: PackedTreeInfos, /// Index of output tree in remaining accounts. Pass to instruction data. pub output_tree_index: u8, + /// Index of state merkle tree in remaining accounts (when included for mint creation). + pub state_tree_index: Option, /// Offset where system accounts start. Pass to instruction data if needed. pub system_accounts_offset: u8, } @@ -81,6 +83,39 @@ pub fn pack_proof( proof: ValidityProofWithContext, output_tree: &TreeInfo, cpi_context: Option, +) -> Result { + pack_proof_internal(program_id, proof, output_tree, cpi_context, false) +} + +/// Packs a validity proof with state merkle tree for mint creation. +/// +/// Same as `pack_proof` but also includes the state merkle tree in remaining accounts. +/// This is required for mint creation because the decompress operation needs the state +/// merkle tree for discriminator validation. +/// +/// # Arguments +/// - `program_id`: Your program's ID +/// - `proof`: Validity proof from `get_validity_proof()` +/// - `output_tree`: Tree info for writing outputs (from `get_random_state_tree_info()`) +/// - `cpi_context`: CPI context pubkey. Required for mint creation. +/// +/// # Returns +/// `PackedProofResult` with `state_tree_index` populated. +pub fn pack_proof_for_mints( + program_id: &Pubkey, + proof: ValidityProofWithContext, + output_tree: &TreeInfo, + cpi_context: Option, +) -> Result { + pack_proof_internal(program_id, proof, output_tree, cpi_context, true) +} + +fn pack_proof_internal( + program_id: &Pubkey, + proof: ValidityProofWithContext, + output_tree: &TreeInfo, + cpi_context: Option, + include_state_tree: bool, ) -> Result { let mut packed = PackedAccounts::default(); @@ -97,7 +132,25 @@ pub fn pack_proof( .unwrap_or(output_tree.queue); let output_tree_index = packed.insert_or_get(output_queue); - let client_packed_tree_infos = proof.pack_tree_infos(&mut packed); + // For mint creation: pack address tree first (must be at index 1 per program validation), + // then state tree. For non-mint: just pack tree infos normally. + let (client_packed_tree_infos, state_tree_index) = if include_state_tree { + // Pack tree infos first to ensure address tree is at index 1 + let tree_infos = proof.pack_tree_infos(&mut packed); + + // Then add state tree (will be after address tree) + let state_tree = output_tree + .next_tree_info + .as_ref() + .map(|n| n.tree) + .unwrap_or(output_tree.tree); + let state_idx = packed.insert_or_get(state_tree); + + (tree_infos, Some(state_idx)) + } else { + let tree_infos = proof.pack_tree_infos(&mut packed); + (tree_infos, None) + }; let (remaining_accounts, system_offset, _) = packed.to_account_metas(); // Convert from light_client's types to our local types @@ -115,6 +168,7 @@ pub fn pack_proof( remaining_accounts, packed_tree_infos, output_tree_index, + state_tree_index, system_accounts_offset: system_offset as u8, }) } diff --git a/sdk-libs/macros/CLAUDE.md b/sdk-libs/macros/CLAUDE.md index dfb6f9724c..f8f229dd1d 100644 --- a/sdk-libs/macros/CLAUDE.md +++ b/sdk-libs/macros/CLAUDE.md @@ -33,9 +33,9 @@ Detailed macro documentation is in the `docs/` directory: src/ ├── lib.rs # Macro entry points ├── rentfree/ # RentFree macro system +│ ├── account/ # Trait derive macros for account data structs │ ├── accounts/ # #[derive(RentFree)] for Accounts structs │ ├── program/ # #[rentfree_program] attribute macro -│ ├── traits/ # Trait derive macros │ └── shared_utils.rs # Common utilities └── hasher/ # LightHasherSha derive macro ``` diff --git a/sdk-libs/macros/docs/CLAUDE.md b/sdk-libs/macros/docs/CLAUDE.md index 5d68b4814e..b6f8348a24 100644 --- a/sdk-libs/macros/docs/CLAUDE.md +++ b/sdk-libs/macros/docs/CLAUDE.md @@ -14,23 +14,34 @@ Documentation for the rentfree macro system in `light-sdk-macros`. These macros | **`rentfree_program/`** | `#[rentfree_program]` attribute macro | | **`rentfree_program/architecture.md`** | Architecture overview, usage, generated items | | **`rentfree_program/codegen.md`** | Technical implementation details (code generation) | -| **`traits/`** | Trait derive macros for compressible data structs | +| **`accounts/`** | Field-level attributes for Accounts structs | +| **`account/`** | Trait derive macros for account data structs | -### Traits Documentation +### Accounts Field Attributes + +Field-level attributes applied inside `#[derive(RentFree)]` Accounts structs: + +| File | Attribute | Description | +|------|-----------|-------------| +| **`accounts/light_mint.md`** | `#[light_mint(...)]` | Creates compressed mint with automatic decompression | + +See also: `#[rentfree]` attribute documented in `rentfree.md` + +### Account Trait Documentation | File | Macro | Description | |------|-------|-------------| -| **`traits/has_compression_info.md`** | `#[derive(HasCompressionInfo)]` | Accessor methods for compression_info field | -| **`traits/compress_as.md`** | `#[derive(CompressAs)]` | Creates compressed representation for hashing | -| **`traits/compressible.md`** | `#[derive(Compressible)]` | Combined: HasCompressionInfo + CompressAs + Size | -| **`traits/compressible_pack.md`** | `#[derive(CompressiblePack)]` | Pack/Unpack with Pubkey-to-index compression | -| **`traits/light_compressible.md`** | `#[derive(LightCompressible)]` | All traits for rent-free accounts | +| **`account/has_compression_info.md`** | `#[derive(HasCompressionInfo)]` | Accessor methods for compression_info field | +| **`account/compress_as.md`** | `#[derive(CompressAs)]` | Creates compressed representation for hashing | +| **`account/compressible.md`** | `#[derive(Compressible)]` | Combined: HasCompressionInfo + CompressAs + Size | +| **`account/compressible_pack.md`** | `#[derive(CompressiblePack)]` | Pack/Unpack with Pubkey-to-index compression | +| **`account/light_compressible.md`** | `#[derive(LightCompressible)]` | All traits for rent-free accounts | ## Navigation Tips ### Starting Points -- **Data struct traits**: Start with `traits/light_compressible.md` for the all-in-one derive macro for compressible data structs +- **Data struct traits**: Start with `account/light_compressible.md` for the all-in-one derive macro for compressible data structs - **Building account structs**: Use `rentfree.md` for the accounts-level derive macro that marks fields for compression - **Program-level integration**: Use `rentfree_program/architecture.md` for program-level auto-discovery and instruction generation - **Implementation details**: Use `rentfree_program/codegen.md` for technical code generation details @@ -52,21 +63,21 @@ Documentation for the rentfree macro system in `light-sdk-macros`. These macros | +-- Generates LightPreInit + LightFinalize impls | - +-- Uses trait derives (traits/): - - HasCompressionInfo <- traits/has_compression_info.md - - CompressAs <- traits/compress_as.md - - Compressible <- traits/compressible.md - - CompressiblePack <- traits/compressible_pack.md - - LightCompressible <- traits/light_compressible.md (combines all) + +-- Uses trait derives (account/): + - HasCompressionInfo <- account/has_compression_info.md + - CompressAs <- account/compress_as.md + - Compressible <- account/compressible.md + - CompressiblePack <- account/compressible_pack.md + - LightCompressible <- account/light_compressible.md (combines all) ``` ## Related Source Code ``` sdk-libs/macros/src/rentfree/ +├── account/ # Trait derive macros for account data structs ├── accounts/ # #[derive(RentFree)] implementation ├── program/ # #[rentfree_program] implementation -├── traits/ # Trait derive macros ├── shared_utils.rs # Common utilities └── mod.rs # Module exports ``` diff --git a/sdk-libs/macros/docs/traits/compress_as.md b/sdk-libs/macros/docs/account/compress_as.md similarity index 100% rename from sdk-libs/macros/docs/traits/compress_as.md rename to sdk-libs/macros/docs/account/compress_as.md diff --git a/sdk-libs/macros/docs/traits/compressible.md b/sdk-libs/macros/docs/account/compressible.md similarity index 100% rename from sdk-libs/macros/docs/traits/compressible.md rename to sdk-libs/macros/docs/account/compressible.md diff --git a/sdk-libs/macros/docs/traits/compressible_pack.md b/sdk-libs/macros/docs/account/compressible_pack.md similarity index 96% rename from sdk-libs/macros/docs/traits/compressible_pack.md rename to sdk-libs/macros/docs/account/compressible_pack.md index e573ef8e2c..d10d657c34 100644 --- a/sdk-libs/macros/docs/traits/compressible_pack.md +++ b/sdk-libs/macros/docs/account/compressible_pack.md @@ -320,6 +320,17 @@ let user_record = packed_record.unpack(ctx.remaining_accounts)?; - All methods are marked `#[inline(never)]` for smaller program size - The packed struct derives `AnchorSerialize` and `AnchorDeserialize` +### Limitation: Option Fields + +Only direct `Pubkey` fields are converted to `u8` indices. `Option` fields remain as `Option` in the packed struct because `None` doesn't map cleanly to an index. + +```rust +pub struct Record { + pub owner: Pubkey, // -> u8 in packed struct + pub delegate: Option, // -> Option in packed struct (unchanged) +} +``` + --- ## 10. Related Macros diff --git a/sdk-libs/macros/docs/traits/has_compression_info.md b/sdk-libs/macros/docs/account/has_compression_info.md similarity index 100% rename from sdk-libs/macros/docs/traits/has_compression_info.md rename to sdk-libs/macros/docs/account/has_compression_info.md diff --git a/sdk-libs/macros/docs/traits/light_compressible.md b/sdk-libs/macros/docs/account/light_compressible.md similarity index 96% rename from sdk-libs/macros/docs/traits/light_compressible.md rename to sdk-libs/macros/docs/account/light_compressible.md index 34a9f026c7..e47af4f03e 100644 --- a/sdk-libs/macros/docs/traits/light_compressible.md +++ b/sdk-libs/macros/docs/account/light_compressible.md @@ -224,11 +224,11 @@ impl light_sdk::compressible::Unpack for PackedUserRecord { ... } ## 7. Hashing Behavior -The `LightHasherSha` component uses SHA256 to hash the entire struct: +The `LightHasherSha` component uses SHA256 to hash the entire struct via borsh serialization: - **No `#[hash]` attributes needed** - SHA256 serializes and hashes all fields - **Type 3 ShaFlat hashing** - Efficient flat serialization for hashing -- The `compression_info` field is included in the serialized form but typically set to `None` +- **`compression_info` IS included in the hash** - The hash is computed over the entire borsh-serialized struct, including `compression_info`. This means records with `Some(CompressionInfo)` will hash differently than records with `None`. In practice, `compression_info` should be set to `None` before hashing to ensure consistent hashes for the same account data. --- diff --git a/sdk-libs/macros/docs/rentfree.md b/sdk-libs/macros/docs/accounts/architecture.md similarity index 97% rename from sdk-libs/macros/docs/rentfree.md rename to sdk-libs/macros/docs/accounts/architecture.md index 24ae6cf515..90aa45788e 100644 --- a/sdk-libs/macros/docs/rentfree.md +++ b/sdk-libs/macros/docs/accounts/architecture.md @@ -410,10 +410,12 @@ pub struct CachedData { ### 3.3 Pack/Unpack (CompressiblePack) -Generates `Pack` and `Unpack` traits with a `Packed{StructName}` struct where Pubkey fields are compressed to u8 indices. +Generates `Pack` and `Unpack` traits with a `Packed{StructName}` struct where direct Pubkey fields are compressed to u8 indices. **Source**: `sdk-libs/macros/src/rentfree/traits/pack_unpack.rs` +**Limitation**: Only direct `Pubkey` fields are converted to `u8` indices. `Option` fields are **NOT** converted - they remain as `Option` in the packed struct. This is because `Option` can be `None`, which doesn't map cleanly to an index. + **Input**: ```rust #[derive(CompressiblePack)] @@ -499,7 +501,8 @@ pub struct UserRecord { **Notes**: - `compression_info` field is auto-detected and handled specially (no `#[skip]` needed) -- SHA256 hashes the entire struct, so no `#[hash]` attributes needed +- SHA256 hashes the entire struct via borsh serialization, so no `#[hash]` attributes needed +- **Important**: `compression_info` IS included in the hash. Set it to `None` before hashing for consistent results. --- diff --git a/sdk-libs/macros/docs/accounts/light_mint.md b/sdk-libs/macros/docs/accounts/light_mint.md new file mode 100644 index 0000000000..ef40bffab0 --- /dev/null +++ b/sdk-libs/macros/docs/accounts/light_mint.md @@ -0,0 +1,269 @@ +# `#[light_mint(...)]` Attribute + +## Overview + +The `#[light_mint(...)]` attribute marks a field in an Anchor Accounts struct for compressed mint creation. When applied to a `CMint` account field, it generates code to create a compressed mint with automatic decompression support. + +**Source**: `sdk-libs/macros/src/rentfree/accounts/light_mint.rs` + +## Usage + +```rust +use light_sdk_macros::RentFree; +use anchor_lang::prelude::*; + +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct CreateMint<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Unchecked account for PDA signer + #[account(seeds = [b"mint_signer"], bump)] + pub mint_signer: AccountInfo<'info>, + + pub authority: Signer<'info>, + + /// The CMint account to create + #[light_mint( + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint_signer", &[ctx.bumps.mint_signer]] + )] + pub cmint: Account<'info, CMint>, + + // Infrastructure accounts (auto-detected by name) + pub ctoken_compressible_config: Account<'info, CtokenConfig>, + pub ctoken_rent_sponsor: Account<'info, CtokenRentSponsor>, + pub light_token_program: Program<'info, LightTokenProgram>, + pub ctoken_cpi_authority: AccountInfo<'info>, +} +``` + +## Required Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `mint_signer` | Field reference | The AccountInfo that seeds the mint PDA. The mint address is derived from this signer. | +| `authority` | Field reference | The mint authority. Either a transaction signer or a PDA (if `authority_seeds` is provided). | +| `decimals` | Expression | Token decimals (e.g., `9` for 9 decimal places). | +| `mint_seeds` | Slice expression | PDA signer seeds for `mint_signer`. Must be a `&[&[u8]]` expression that matches the `#[account(seeds = ...)]` on `mint_signer`, **including the bump**. | + +## Optional Attributes + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `address_tree_info` | Expression | `params.create_accounts_proof.address_tree_info` | `PackedAddressTreeInfo` containing tree indices. | +| `freeze_authority` | Field reference | None | Optional freeze authority field. | +| `authority_seeds` | Slice expression | None | PDA signer seeds for `authority`. If not provided, `authority` must be a transaction signer. | +| `rent_payment` | Expression | `2u8` | Rent payment epochs for decompression. | +| `write_top_up` | Expression | `0u32` | Write top-up lamports for decompression. | + +## How It Works + +### Mint PDA Derivation + +The mint address is derived from the `mint_signer` field: + +```rust +let (mint_pda, bump) = light_token_sdk::token::find_mint_address(mint_signer.key); +``` + +### Signer Seeds (mint_seeds) + +The `mint_seeds` attribute provides the PDA signer seeds used for `invoke_signed` when calling the light token program. These seeds must derive to the `mint_signer` pubkey for the CPI to succeed. + +```rust +#[light_mint( + mint_signer = mint_signer, + authority = mint_authority, + decimals = 9, + mint_seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]] +)] +pub cmint: UncheckedAccount<'info>, +``` + +**Syntax notes:** +- Use `self.field` to reference accounts in the struct +- Use `.to_account_info().key` to get account pubkeys +- The bump must be passed explicitly (typically via instruction params) + +The generated code uses these seeds to sign the CPI: + +```rust +let mint_seeds: &[&[u8]] = &[...]; // from mint_seeds attribute +invoke_signed(&mint_action_ix, &account_infos, &[mint_seeds])?; +``` + +### Generated Code Flow + +1. **Resolve tree accounts** - Get address tree and output queue from CPI accounts +2. **Derive mint PDA** - Calculate mint address from `mint_signer` +3. **Extract proof** - Get compression proof from instruction params +4. **Build mint instruction data** - Create `MintInstructionData` with metadata +5. **Configure decompression** - Set `rent_payment` and `write_top_up` for decompression +6. **Build account metas** - Configure CPI accounts for mint_action +7. **Invoke CPI** - Call light_token_program with signer seeds + +### CPI Context Integration + +When used alongside `#[rentfree]` PDAs, the mint is batched with PDA compression in a single CPI context. The mint receives an `assigned_account_index` to order it relative to PDAs. + +## Examples + +### Basic Mint Creation + +```rust +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct CreateBasicMint<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Mint signer PDA + #[account(seeds = [b"mint"], bump)] + pub mint_signer: AccountInfo<'info>, + + pub authority: Signer<'info>, + + #[light_mint( + mint_signer = mint_signer, + authority = authority, + decimals = 6, + mint_seeds = &[b"mint", &[ctx.bumps.mint_signer]] + )] + pub cmint: Account<'info, CMint>, + + // ... infrastructure accounts +} +``` + +### Mint with PDA Authority + +When the authority is a PDA, provide `authority_seeds`: + +```rust +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct CreateMintWithPdaAuthority<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Mint signer PDA + #[account(seeds = [b"mint"], bump)] + pub mint_signer: AccountInfo<'info>, + + /// CHECK: Authority PDA (not a signer) + #[account(seeds = [b"authority"], bump)] + pub authority: AccountInfo<'info>, + + #[light_mint( + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint", &[ctx.bumps.mint_signer]], + authority_seeds = &[b"authority", &[ctx.bumps.authority]] + )] + pub cmint: Account<'info, CMint>, + + // ... infrastructure accounts +} +``` + +### Mint with Freeze Authority + +```rust +#[light_mint( + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint", &[bump]], + freeze_authority = freeze_auth +)] +pub cmint: Account<'info, CMint>, + +/// Optional freeze authority +pub freeze_auth: Signer<'info>, +``` + +### Custom Decompression Settings + +```rust +#[light_mint( + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint", &[bump]], + rent_payment = 4, // 4 epochs of rent + write_top_up = 1000 // Extra lamports for writes +)] +pub cmint: Account<'info, CMint>, +``` + +### Combined with #[rentfree] PDAs + +```rust +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct CreateMintAndPda<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Mint signer + #[account(seeds = [b"mint"], bump)] + pub mint_signer: AccountInfo<'info>, + + pub authority: Signer<'info>, + + #[light_mint( + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint", &[ctx.bumps.mint_signer]] + )] + pub cmint: Account<'info, CMint>, + + #[account( + init, + payer = fee_payer, + space = 8 + TokenAccount::INIT_SPACE, + seeds = [b"token", params.owner.as_ref()], + bump + )] + #[rentfree] + pub token_account: Account<'info, TokenAccount>, + + // ... infrastructure accounts +} +``` + +When both `#[light_mint]` and `#[rentfree]` are present, the macro: +1. Processes PDAs first, writing them to the CPI context +2. Invokes mint_action with CPI context to batch the mint creation +3. Uses `assigned_account_index` to order the mint relative to PDAs + +## Infrastructure Accounts + +The macro requires certain infrastructure accounts, auto-detected by naming convention: + +| Account Type | Accepted Names | +|--------------|----------------| +| Fee Payer | `fee_payer`, `payer`, `creator` | +| CToken Config | `ctoken_compressible_config`, `ctoken_config`, `light_token_config_account` | +| CToken Rent Sponsor | `ctoken_rent_sponsor`, `light_token_rent_sponsor` | +| CToken Program | `ctoken_program`, `light_token_program` | +| CToken CPI Authority | `ctoken_cpi_authority`, `light_token_program_cpi_authority`, `compress_token_program_cpi_authority` | + +## Validation + +The macro validates at compile time: +- `mint_signer`, `authority`, `decimals`, and `mint_seeds` are required +- `#[instruction(...)]` attribute must be present on the struct +- If `authority_seeds` is not provided, the generated code verifies `authority` is a transaction signer at runtime + +## Related Documentation + +- **`../rentfree.md`** - Full RentFree derive macro documentation +- **`../rentfree_program/`** - Program-level `#[rentfree_program]` macro +- **`../account/`** - Trait derives for data structs diff --git a/sdk-libs/macros/docs/rentfree_program/architecture.md b/sdk-libs/macros/docs/rentfree_program/architecture.md index 9bed62d7e3..b26aec3d04 100644 --- a/sdk-libs/macros/docs/rentfree_program/architecture.md +++ b/sdk-libs/macros/docs/rentfree_program/architecture.md @@ -146,7 +146,7 @@ light_finalize: Complete compression via CPI Account exists as compressed state + temporary PDA ``` -**Decompress (Read/Modify)** +**Decompress PDAs (Read/Modify)** ``` Client fetches compressed account from indexer | @@ -160,6 +160,27 @@ PDA recreated on-chain from compressed state User interacts with standard Anchor account ``` +**Decompress Token Accounts and Mints** + +Token accounts (ATAs) and mints are decompressed directly via the ctoken program, not through the generated `decompress_accounts_idempotent` instruction: + +``` +Client fetches compressed token account/mint from indexer + | + v +Client calls ctoken program's decompress instruction directly + | + v +Token account or mint recreated on-chain + | + v +User interacts with decompressed ctoken account/mint +``` + +This separation exists because: +- **PDAs**: Program-specific, seeds defined by your program, decompressed via generated instruction +- **Token accounts/mints**: Standard ctoken format, decompressed via ctoken program + **Re-Compress (Return to compressed)** ``` Authority calls compress_accounts_idempotent diff --git a/sdk-libs/macros/src/lib.rs b/sdk-libs/macros/src/lib.rs index 369ae0dd42..0d42596617 100644 --- a/sdk-libs/macros/src/lib.rs +++ b/sdk-libs/macros/src/lib.rs @@ -124,7 +124,9 @@ pub fn light_hasher_sha(input: TokenStream) -> TokenStream { #[proc_macro_derive(HasCompressionInfo)] pub fn has_compression_info(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - into_token_stream(rentfree::traits::traits::derive_has_compression_info(input)) + into_token_stream(rentfree::account::traits::derive_has_compression_info( + input, + )) } /// Legacy CompressAs trait implementation (use Compressible instead). @@ -164,7 +166,7 @@ pub fn has_compression_info(input: TokenStream) -> TokenStream { #[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(rentfree::traits::traits::derive_compress_as(input)) + into_token_stream(rentfree::account::traits::derive_compress_as(input)) } /// Auto-discovering rent-free program macro that reads external module files. @@ -257,7 +259,7 @@ pub fn account(_: TokenStream, input: TokenStream) -> TokenStream { #[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(rentfree::traits::traits::derive_compressible(input)) + into_token_stream(rentfree::account::traits::derive_compressible(input)) } /// Automatically implements Pack and Unpack traits for compressible accounts. @@ -284,7 +286,7 @@ pub fn compressible_derive(input: TokenStream) -> TokenStream { #[proc_macro_derive(CompressiblePack)] pub fn compressible_pack(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - into_token_stream(rentfree::traits::pack_unpack::derive_compressible_pack( + into_token_stream(rentfree::account::pack_unpack::derive_compressible_pack( input, )) } @@ -334,7 +336,7 @@ pub fn compressible_pack(input: TokenStream) -> TokenStream { #[proc_macro_derive(RentFreeAccount, attributes(compress_as))] pub fn rent_free_account(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - into_token_stream(rentfree::traits::light_compressible::derive_rentfree_account(input)) + into_token_stream(rentfree::account::light_compressible::derive_rentfree_account(input)) } /// Derives a Rent Sponsor PDA for a program at compile time. diff --git a/sdk-libs/macros/src/rentfree/traits/decompress_context.rs b/sdk-libs/macros/src/rentfree/account/decompress_context.rs similarity index 70% rename from sdk-libs/macros/src/rentfree/traits/decompress_context.rs rename to sdk-libs/macros/src/rentfree/account/decompress_context.rs index e7680cb71e..31fd02aecf 100644 --- a/sdk-libs/macros/src/rentfree/traits/decompress_context.rs +++ b/sdk-libs/macros/src/rentfree/account/decompress_context.rs @@ -31,11 +31,16 @@ pub fn generate_decompress_context_trait_impl( // Use variant_name for CtxSeeds struct (matches what decompress.rs generates) let ctx_seeds_struct_name = format_ident!("{}CtxSeeds", variant_name); let ctx_fields = &info.ctx_seed_fields; + let params_only_fields = &info.params_only_seed_fields; // Generate pattern to extract idx fields from packed variant let idx_field_patterns: Vec<_> = ctx_fields.iter().map(|field| { let idx_field = format_ident!("{}_idx", field); quote! { #idx_field } }).collect(); + // Generate pattern to extract params-only fields from packed variant + let params_field_patterns: Vec<_> = params_only_fields.iter().map(|(field, _, _)| { + quote! { #field } + }).collect(); // Generate code to resolve idx fields to Pubkeys let resolve_ctx_seeds: Vec<_> = ctx_fields.iter().map(|field| { let idx_field = format_ident!("{}_idx", field); @@ -55,58 +60,42 @@ pub fn generate_decompress_context_trait_impl( }).collect(); quote! { let ctx_seeds = #ctx_seeds_struct_name { #(#field_inits),* }; } }; - if ctx_fields.is_empty() { - quote! { - RentFreeAccountVariant::#packed_variant_name { data: packed, .. } => { - #ctx_seeds_construction - match light_sdk::compressible::handle_packed_pda_variant::<#inner_type, #packed_inner_type, _, _>( - &*self.rent_sponsor, - cpi_accounts, - address_space, - &solana_accounts[i], - i, - &packed, - &meta, - post_system_accounts, - &mut compressed_pda_infos, - &program_id, - &ctx_seeds, - seed_params, - ) { - std::result::Result::Ok(()) => {}, - std::result::Result::Err(e) => return std::result::Result::Err(e), - } - } - RentFreeAccountVariant::#variant_name { .. } => { - unreachable!("Unpacked variants should not be present during decompression"); - } - } + // Generate SeedParams update with params-only field values + // Note: variant_seed_params is declared OUTSIDE the match to avoid borrow checker issues + // (the reference passed to handle_packed_pda_variant would outlive the match arm scope) + // params-only fields are stored directly in packed variant (not by reference), + // so we use the value directly without dereferencing + let seed_params_update = if params_only_fields.is_empty() { + // No update needed - use the default value declared before match + quote! {} } else { - quote! { - RentFreeAccountVariant::#packed_variant_name { data: packed, #(#idx_field_patterns,)* .. } => { - #(#resolve_ctx_seeds)* - #ctx_seeds_construction - match light_sdk::compressible::handle_packed_pda_variant::<#inner_type, #packed_inner_type, _, _>( - &*self.rent_sponsor, - cpi_accounts, - address_space, - &solana_accounts[i], - i, - &packed, - &meta, - post_system_accounts, - &mut compressed_pda_infos, - &program_id, - &ctx_seeds, - seed_params, - ) { - std::result::Result::Ok(()) => {}, - std::result::Result::Err(e) => return std::result::Result::Err(e), - } - } - RentFreeAccountVariant::#variant_name { .. } => { - unreachable!("Unpacked variants should not be present during decompression"); - } + let field_inits: Vec<_> = params_only_fields.iter().map(|(field, _, _)| { + quote! { #field: std::option::Option::Some(#field) } + }).collect(); + quote! { variant_seed_params = SeedParams { #(#field_inits,)* ..Default::default() }; } + }; + quote! { + RentFreeAccountVariant::#packed_variant_name { data: packed, #(#idx_field_patterns,)* #(#params_field_patterns,)* .. } => { + #(#resolve_ctx_seeds)* + #ctx_seeds_construction + #seed_params_update + light_sdk::compressible::handle_packed_pda_variant::<#inner_type, #packed_inner_type, _, _>( + &*self.rent_sponsor, + cpi_accounts, + address_space, + &solana_accounts[i], + i, + &packed, + &meta, + post_system_accounts, + &mut compressed_pda_infos, + &program_id, + &ctx_seeds, + std::option::Option::Some(&variant_seed_params), + )?; + } + RentFreeAccountVariant::#variant_name { .. } => { + unreachable!("Unpacked variants should not be present during decompression"); } } }) @@ -119,7 +108,7 @@ pub fn generate_decompress_context_trait_impl( type CompressedData = RentFreeAccountData; type PackedTokenData = light_token_sdk::compat::PackedCTokenData<#packed_token_variant_ident>; type CompressedMeta = light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; - type SeedParams = (); + type SeedParams = SeedParams; fn fee_payer(&self) -> &solana_account_info::AccountInfo<#lifetime> { &*self.fee_payer @@ -160,21 +149,30 @@ pub fn generate_decompress_context_trait_impl( Vec, Vec<(Self::PackedTokenData, Self::CompressedMeta)>, ), solana_program_error::ProgramError> { + solana_msg::msg!("collect_pda_and_token: start, {} accounts", compressed_accounts.len()); 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; + solana_msg::msg!("collect_pda_and_token: allocating vecs"); let mut compressed_pda_infos = Vec::with_capacity(compressed_accounts.len()); let mut compressed_token_accounts = Vec::with_capacity(compressed_accounts.len()); + solana_msg::msg!("collect_pda_and_token: starting loop"); for (i, compressed_data) in compressed_accounts.into_iter().enumerate() { + solana_msg::msg!("collect_pda_and_token: processing account {}", i); let meta = compressed_data.meta; + // Declare variant_seed_params OUTSIDE the match to avoid borrow checker issues + // (reference passed to handle_packed_pda_variant with ? would outlive match arm scope) + let mut variant_seed_params = SeedParams::default(); match compressed_data.data { #(#pda_match_arms)* RentFreeAccountVariant::PackedCTokenData(mut data) => { + solana_msg::msg!("collect_pda_and_token: token variant {}", i); data.token_data.version = 3; compressed_token_accounts.push((data, meta)); + solana_msg::msg!("collect_pda_and_token: token {} done", i); } RentFreeAccountVariant::CTokenData(_) => { unreachable!(); @@ -182,6 +180,7 @@ pub fn generate_decompress_context_trait_impl( } } + solana_msg::msg!("collect_pda_and_token: loop done, pdas={} tokens={}", compressed_pda_infos.len(), compressed_token_accounts.len()); std::result::Result::Ok((compressed_pda_infos, compressed_token_accounts)) } diff --git a/sdk-libs/macros/src/rentfree/traits/light_compressible.rs b/sdk-libs/macros/src/rentfree/account/light_compressible.rs similarity index 98% rename from sdk-libs/macros/src/rentfree/traits/light_compressible.rs rename to sdk-libs/macros/src/rentfree/account/light_compressible.rs index bc0863f072..ae2044b5a9 100644 --- a/sdk-libs/macros/src/rentfree/traits/light_compressible.rs +++ b/sdk-libs/macros/src/rentfree/account/light_compressible.rs @@ -13,7 +13,7 @@ use syn::{DeriveInput, Fields, ItemStruct, Result}; use crate::{ discriminator::discriminator, hasher::derive_light_hasher_sha, - rentfree::traits::{pack_unpack::derive_compressible_pack, traits::derive_compressible}, + rentfree::account::{pack_unpack::derive_compressible_pack, traits::derive_compressible}, }; /// Derives all required traits for a compressible account. diff --git a/sdk-libs/macros/src/rentfree/traits/mod.rs b/sdk-libs/macros/src/rentfree/account/mod.rs similarity index 100% rename from sdk-libs/macros/src/rentfree/traits/mod.rs rename to sdk-libs/macros/src/rentfree/account/mod.rs diff --git a/sdk-libs/macros/src/rentfree/traits/pack_unpack.rs b/sdk-libs/macros/src/rentfree/account/pack_unpack.rs similarity index 97% rename from sdk-libs/macros/src/rentfree/traits/pack_unpack.rs rename to sdk-libs/macros/src/rentfree/account/pack_unpack.rs index 01ed4b99e4..df59bc2fcf 100644 --- a/sdk-libs/macros/src/rentfree/traits/pack_unpack.rs +++ b/sdk-libs/macros/src/rentfree/account/pack_unpack.rs @@ -52,7 +52,9 @@ fn generate_with_packed_struct( 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) } + // Use read-only since pubkey fields are references (owner, authority, etc.) + // not accounts that need to be modified + quote! { #field_name: remaining_accounts.insert_or_get_read_only(self.#field_name) } } else if is_copy_type(field_type) { quote! { #field_name: self.#field_name } } else { diff --git a/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs b/sdk-libs/macros/src/rentfree/account/seed_extraction.rs similarity index 87% rename from sdk-libs/macros/src/rentfree/traits/seed_extraction.rs rename to sdk-libs/macros/src/rentfree/account/seed_extraction.rs index 104b055a50..fc6150f614 100644 --- a/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs +++ b/sdk-libs/macros/src/rentfree/account/seed_extraction.rs @@ -69,6 +69,8 @@ pub struct ExtractedAccountsInfo { pub struct_name: Ident, pub pda_fields: Vec, pub token_fields: Vec, + /// True if struct has any #[light_mint] fields + pub has_light_mint_fields: bool, } /// Extract rentfree field info from an Accounts struct @@ -82,6 +84,7 @@ pub fn extract_from_accounts_struct( let mut pda_fields = Vec::new(); let mut token_fields = Vec::new(); + let mut has_light_mint_fields = false; for field in fields { let field_ident = match &field.ident { @@ -95,6 +98,16 @@ pub fn extract_from_accounts_struct( .iter() .any(|attr| attr.path().is_ident("rentfree")); + // Check for #[light_mint(...)] attribute + let has_light_mint = field + .attrs + .iter() + .any(|attr| attr.path().is_ident("light_mint")); + + if has_light_mint { + has_light_mint_fields = true; + } + // Check for #[rentfree_token(...)] attribute let token_attr = extract_rentfree_token_attr(&field.attrs); @@ -146,8 +159,8 @@ pub fn extract_from_accounts_struct( } } - // If no rentfree fields found, return None - if pda_fields.is_empty() && token_fields.is_empty() { + // If no rentfree/light_mint fields found, return None + if pda_fields.is_empty() && token_fields.is_empty() && !has_light_mint_fields { return Ok(None); } @@ -190,6 +203,7 @@ pub fn extract_from_accounts_struct( struct_name: item.ident.clone(), pda_fields, token_fields, + has_light_mint_fields, })) } @@ -621,3 +635,68 @@ pub fn get_data_fields(seeds: &[ClassifiedSeed]) -> Vec<(Ident, Option)> } fields } + +/// Get params-only seed fields from a TokenSeedSpec. +/// This is a convenience wrapper that works with the SeedElement type. +pub fn get_params_only_seed_fields_from_spec( + spec: &crate::rentfree::program::instructions::TokenSeedSpec, + state_field_names: &std::collections::HashSet, +) -> Vec<(Ident, syn::Type, bool)> { + use crate::rentfree::program::instructions::SeedElement; + + let mut fields = Vec::new(); + for seed in &spec.seeds { + if let SeedElement::Expression(expr) = seed { + if let Some((field_name, has_conversion)) = extract_data_field_from_expr(expr) { + let field_str = field_name.to_string(); + // Only include fields that are NOT on the state struct and not already added + if !state_field_names.contains(&field_str) + && !fields + .iter() + .any(|(f, _, _): &(Ident, _, _)| f == &field_name) + { + let field_type: syn::Type = if has_conversion { + syn::parse_quote!(u64) + } else { + syn::parse_quote!(solana_pubkey::Pubkey) + }; + fields.push((field_name, field_type, has_conversion)); + } + } + } + } + fields +} + +/// Extract data field name and conversion info from an expression. +/// Returns (field_name, has_conversion) if the expression is a data.* field. +fn extract_data_field_from_expr(expr: &syn::Expr) -> Option<(Ident, bool)> { + use crate::rentfree::shared_utils::is_base_path; + + match expr { + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + if is_base_path(&field_expr.base, "data") { + return Some((field_name.clone(), false)); + } + } + None + } + syn::Expr::MethodCall(method_call) => { + // Handle data.field.to_le_bytes().as_ref() etc. + let has_bytes_conversion = + method_call.method == "to_le_bytes" || method_call.method == "to_be_bytes"; + if has_bytes_conversion { + return extract_data_field_from_expr(&method_call.receiver) + .map(|(name, _)| (name, true)); + } + // For .as_ref(), recurse without marking conversion + if method_call.method == "as_ref" || method_call.method == "as_bytes" { + return extract_data_field_from_expr(&method_call.receiver); + } + None + } + syn::Expr::Reference(ref_expr) => extract_data_field_from_expr(&ref_expr.expr), + _ => None, + } +} diff --git a/sdk-libs/macros/src/rentfree/traits/traits.rs b/sdk-libs/macros/src/rentfree/account/traits.rs similarity index 100% rename from sdk-libs/macros/src/rentfree/traits/traits.rs rename to sdk-libs/macros/src/rentfree/account/traits.rs diff --git a/sdk-libs/macros/src/rentfree/traits/utils.rs b/sdk-libs/macros/src/rentfree/account/utils.rs similarity index 100% rename from sdk-libs/macros/src/rentfree/traits/utils.rs rename to sdk-libs/macros/src/rentfree/account/utils.rs diff --git a/sdk-libs/macros/src/rentfree/accounts/builder.rs b/sdk-libs/macros/src/rentfree/accounts/builder.rs index a6d0cee7bb..14ba9d463c 100644 --- a/sdk-libs/macros/src/rentfree/accounts/builder.rs +++ b/sdk-libs/macros/src/rentfree/accounts/builder.rs @@ -8,7 +8,7 @@ use quote::quote; use syn::DeriveInput; use super::{ - light_mint::{InfraRefs, LightMintBuilder}, + light_mint::{InfraRefs, LightMintsBuilder}, parse::ParsedRentFreeStruct, pda::generate_pda_compress_blocks, }; @@ -96,8 +96,8 @@ impl RentFreeBuilder { /// Generate LightPreInit body for PDAs + mints: /// 1. Write PDAs to CPI context - /// 2. Invoke mint_action with decompress + CPI context - /// After this, Mint is "hot" and usable in instruction body + /// 2. Invoke CreateMintsCpi with CPI context offset + /// After this, Mints are "hot" and usable in instruction body pub fn generate_pre_init_pdas_and_mints(&self) -> TokenStream { let (compress_blocks, new_addr_idents) = generate_pda_compress_blocks(&self.parsed.rentfree_fields); @@ -117,17 +117,10 @@ impl RentFreeBuilder { // Get the first PDA's output tree index (for the state tree output queue) let first_pda_output_tree = &self.parsed.rentfree_fields[0].output_tree; - // TODO(diff-pr): Support multiple #[light_mint] fields by looping here. - // Each mint would get assigned_account_index = pda_count + mint_index. - // Also add support for #[rentfree_token] fields for token ATAs. - let mint = &self.parsed.light_mint_fields[0]; - - // assigned_account_index for mint is after PDAs - let mint_assigned_index = pda_count as u8; - - // Generate mint action invocation with CPI context - let mint_invocation = LightMintBuilder::new(mint, params_ident, &self.infra) - .with_cpi_context(quote! { #first_pda_output_tree }, mint_assigned_index) + // Generate CreateMintsCpi invocation for all mints with PDA context offset + let mints = &self.parsed.light_mint_fields; + let mint_invocation = LightMintsBuilder::new(mints, params_ident, &self.infra) + .with_pda_context(pda_count, quote! { #first_pda_output_tree }) .generate_invocation(); // Infrastructure field references for quote! interpolation @@ -171,7 +164,7 @@ impl RentFreeBuilder { .write_to_cpi_context_first() .invoke_write_to_cpi_context_first(cpi_context_accounts)?; - // Step 2: Build and invoke mint_action with decompress + CPI context + // Step 2: Create mints using CreateMintsCpi with CPI context offset #mint_invocation Ok(true) @@ -179,8 +172,8 @@ impl RentFreeBuilder { } /// Generate LightPreInit body for mints-only (no PDAs): - /// Invoke mint_action with decompress directly - /// After this, CMint is "hot" and usable in instruction body + /// Invoke CreateMintsCpi with decompress directly + /// After this, Mints are "hot" and usable in instruction body pub fn generate_pre_init_mints_only(&self) -> TokenStream { // Get instruction param ident let params_ident = &self @@ -192,27 +185,23 @@ impl RentFreeBuilder { .unwrap() .name; - // TODO(diff-pr): Support multiple #[light_mint] fields by looping here. - // Each mint would get assigned_account_index = mint_index. - // Also add support for #[rentfree_token] fields for token ATAs. - let mint = &self.parsed.light_mint_fields[0]; - - // Generate mint action invocation without CPI context + // Generate CreateMintsCpi invocation for all mints (no PDA context) + let mints = &self.parsed.light_mint_fields; let mint_invocation = - LightMintBuilder::new(mint, params_ident, &self.infra).generate_invocation(); + LightMintsBuilder::new(mints, params_ident, &self.infra).generate_invocation(); // Infrastructure field reference for quote! interpolation let fee_payer = &self.infra.fee_payer; quote! { - // Build CPI accounts (no CPI context needed for mints-only) - let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( + // Build CPI accounts with CPI context enabled (mints use CPI context for batching) + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new_with_config( &self.#fee_payer, _remaining, - crate::LIGHT_CPI_SIGNER, + light_sdk::cpi::CpiAccountsConfig::new_with_cpi_context(crate::LIGHT_CPI_SIGNER), ); - // Build and invoke mint_action with decompress + // Create mints using CreateMintsCpi #mint_invocation Ok(true) diff --git a/sdk-libs/macros/src/rentfree/accounts/light_mint.rs b/sdk-libs/macros/src/rentfree/accounts/light_mint.rs index cbdbebfa40..42f2fb60a6 100644 --- a/sdk-libs/macros/src/rentfree/accounts/light_mint.rs +++ b/sdk-libs/macros/src/rentfree/accounts/light_mint.rs @@ -143,7 +143,6 @@ pub(super) struct InfraRefs { pub compression_config: TokenStream, pub ctoken_config: TokenStream, pub ctoken_rent_sponsor: TokenStream, - pub light_token_program: TokenStream, pub ctoken_cpi_authority: TokenStream, } @@ -158,7 +157,6 @@ impl InfraRefs { &infra.ctoken_rent_sponsor, "ctoken_rent_sponsor", ), - light_token_program: resolve_field_name(&infra.ctoken_program, "light_token_program"), ctoken_cpi_authority: resolve_field_name( &infra.ctoken_cpi_authority, "ctoken_cpi_authority", @@ -167,290 +165,264 @@ impl InfraRefs { } } -/// Parts of generated code that differ based on CPI context presence. +/// Builder for generating code that creates multiple compressed mints using CreateMintsCpi. /// -/// - **With CPI context**: Used when batching mint creation with PDA compression. -/// The mint shares output tree with PDAs, uses assigned_account_index for ordering. -/// -/// - **Without CPI context**: Used for mint-only instructions. -/// The mint uses its own address tree info directly. -struct CpiContextParts { - /// Queue access expression (how to get output queue index) - queue_access: TokenStream, - /// Setup block (defines __output_tree_index if needed) - setup: TokenStream, - /// Method chain for CPI context configuration on instruction data - chain: TokenStream, - /// Meta config assignment (sets cpi_context on meta_config) - meta_assignment: TokenStream, - /// Variable binding for instruction_data (mut or not) - data_binding: TokenStream, -} - -impl CpiContextParts { - fn new(cpi_context: &Option<(TokenStream, u8)>) -> Self { - match cpi_context { - Some((tree_expr, assigned_idx)) => Self { - // With CPI context - batching with PDAs - queue_access: quote! { __output_tree_index as usize }, - setup: quote! { let __output_tree_index = #tree_expr; }, - chain: quote! { - .with_cpi_context(light_token_interface::instructions::mint_action::CpiContext { - address_tree_pubkey: __tree_pubkey.to_bytes(), - set_context: false, - first_set_context: false, - in_tree_index: #tree_expr + 1, - in_queue_index: #tree_expr, - out_queue_index: #tree_expr, - token_out_queue_index: 0, - assigned_account_index: #assigned_idx, - read_only_address_trees: [0; 4], - }) - }, - meta_assignment: quote! { meta_config.cpi_context = Some(*cpi_accounts.cpi_context()?.key); }, - data_binding: quote! { let mut instruction_data }, - }, - None => Self { - // Without CPI context - mint only - queue_access: quote! { __tree_info.address_queue_pubkey_index as usize }, - setup: quote! {}, - chain: quote! {}, - meta_assignment: quote! {}, - data_binding: quote! { let instruction_data }, - }, - } - } -} - -/// Builder for mint code generation. +/// This replaces the previous single-mint LightMintBuilder with support for N mints. +/// Generated code uses `CreateMintsCpi` from light_token_sdk for optimal batching. /// /// Usage: /// ```ignore -/// LightMintBuilder::new(mint, params_ident, &infra) -/// .with_cpi_context(quote! { #first_pda_output_tree }, mint_assigned_index) +/// LightMintsBuilder::new(mints, params_ident, &infra) +/// .with_pda_context(pda_count, quote! { #first_pda_output_tree }) /// .generate_invocation() /// ``` -pub(super) struct LightMintBuilder<'a> { - mint: &'a LightMintField, +pub(super) struct LightMintsBuilder<'a> { + mints: &'a [LightMintField], params_ident: &'a Ident, infra: &'a InfraRefs, - cpi_context: Option<(TokenStream, u8)>, + /// PDA context: (pda_count, output_tree_expr) for batching with PDAs + pda_context: Option<(usize, TokenStream)>, } -impl<'a> LightMintBuilder<'a> { +impl<'a> LightMintsBuilder<'a> { /// Create builder with required fields. - pub fn new(mint: &'a LightMintField, params_ident: &'a Ident, infra: &'a InfraRefs) -> Self { + pub fn new(mints: &'a [LightMintField], params_ident: &'a Ident, infra: &'a InfraRefs) -> Self { Self { - mint, + mints, params_ident, infra, - cpi_context: None, + pda_context: None, } } - /// Configure CPI context for batching with PDAs. - pub fn with_cpi_context(mut self, tree_expr: TokenStream, assigned_idx: u8) -> Self { - self.cpi_context = Some((tree_expr, assigned_idx)); + /// Configure for batching with PDAs. + /// + /// When PDAs are written to CPI context first, this sets the offset for mint indices + /// so they don't collide with PDA indices. + pub fn with_pda_context(mut self, pda_count: usize, output_tree_expr: TokenStream) -> Self { + self.pda_context = Some((pda_count, output_tree_expr)); self } - /// Generate mint_action CPI invocation code. + /// Generate CreateMintsCpi invocation code for all mints. pub fn generate_invocation(self) -> TokenStream { - generate_mint_invocation(&self) + generate_mints_invocation(&self) } } -/// Generate mint_action invocation code. +/// Generate CreateMintsCpi invocation code for multiple mints. /// -/// This is the main orchestration function. Shows the high-level flow: -/// 1. Determine CPI context parts (single branching point for all CPI differences) -/// 2. Generate optional field expressions (signer_seeds, freeze_authority, etc.) -/// 3. Generate the complete mint_action CPI invocation block -fn generate_mint_invocation(builder: &LightMintBuilder) -> TokenStream { - let mint = builder.mint; +/// Flow: +/// 1. For each mint: derive PDA, build SingleMintParams +/// 2. Build arrays for mint_seed_accounts, mints +/// 3. Construct CreateMintsCpi struct +/// 4. Call invoke() - seeds are extracted from SingleMintParams internally +fn generate_mints_invocation(builder: &LightMintsBuilder) -> TokenStream { + let mints = builder.mints; let params_ident = builder.params_ident; - let infra = &builder.infra; - - // 2. Generate optional field expressions - let mint_seeds = &mint.mint_seeds; - let authority_seeds = &mint.authority_seeds; - let freeze_authority = mint - .freeze_authority - .as_ref() - .map(|f| quote! { Some(*self.#f.to_account_info().key) }) - .unwrap_or_else(|| quote! { None }); - let rent_payment = quote_option_or(&mint.rent_payment, quote! { 2u8 }); - let write_top_up = quote_option_or(&mint.write_top_up, quote! { 0u32 }); - - // 3. Generate the mint_action CPI block - let mint_field_ident = &mint.field_ident; - let mint_signer = &mint.mint_signer; - let authority = &mint.authority; - let decimals = &mint.decimals; - let address_tree_info = &mint.address_tree_info; + let infra = builder.infra; + let mint_count = mints.len(); + // Infrastructure field references let fee_payer = &infra.fee_payer; let ctoken_config = &infra.ctoken_config; let ctoken_rent_sponsor = &infra.ctoken_rent_sponsor; - let light_token_program = &infra.light_token_program; let ctoken_cpi_authority = &infra.ctoken_cpi_authority; - // 1. Determine CPI context parts (single branching point) - let cpi = CpiContextParts::new(&builder.cpi_context); - - // Destructure CPI parts for use in quote - let CpiContextParts { - queue_access, - setup: cpi_setup, - chain: cpi_chain, - meta_assignment: cpi_meta_assignment, - data_binding, - } = cpi; - - // Generate invoke_signed call with appropriate signer seeds - let invoke_signed_call = match authority_seeds { - Some(auth_seeds) => { + // Determine CPI context offset based on PDA context + let (cpi_context_offset, output_tree_setup) = match &builder.pda_context { + Some((pda_count, tree_expr)) => { + let offset = *pda_count as u8; + ( + quote! { #offset }, + quote! { let __output_tree_index = #tree_expr; }, + ) + } + None => (quote! { 0u8 }, quote! {}), + }; + + // Generate code for each mint to build SingleMintParams + let mint_params_builds: Vec = mints + .iter() + .enumerate() + .map(|(idx, mint)| { + let mint_signer = &mint.mint_signer; + let authority = &mint.authority; + let decimals = &mint.decimals; + let address_tree_info = &mint.address_tree_info; + let freeze_authority = mint + .freeze_authority + .as_ref() + .map(|f| quote! { Some(*self.#f.to_account_info().key) }) + .unwrap_or_else(|| quote! { None }); + let mint_seeds = &mint.mint_seeds; + let authority_seeds = &mint.authority_seeds; + + let idx_ident = format_ident!("__mint_param_{}", idx); + let pda_ident = format_ident!("__mint_pda_{}", idx); + let bump_ident = format_ident!("__mint_bump_{}", idx); + let signer_key_ident = format_ident!("__mint_signer_key_{}", idx); + let mint_seeds_ident = format_ident!("__mint_seeds_{}", idx); + let authority_seeds_ident = format_ident!("__authority_seeds_{}", idx); + + // Generate optional authority seeds binding + let authority_seeds_binding = match authority_seeds { + Some(seeds) => quote! { + let #authority_seeds_ident: &[&[u8]] = #seeds; + let #authority_seeds_ident = Some(#authority_seeds_ident); + }, + None => quote! { + let #authority_seeds_ident: Option<&[&[u8]]> = None; + }, + }; + quote! { - let authority_seeds: &[&[u8]] = #auth_seeds; - anchor_lang::solana_program::program::invoke_signed( - &mint_action_ix, - &account_infos, - &[mint_seeds, authority_seeds] - )?; + // Mint #idx: derive PDA and build params + let #signer_key_ident = *self.#mint_signer.to_account_info().key; + let (#pda_ident, #bump_ident) = light_token_sdk::token::find_mint_address(&#signer_key_ident); + + let #mint_seeds_ident: &[&[u8]] = #mint_seeds; + #authority_seeds_binding + + let __tree_info = &#address_tree_info; + + let #idx_ident = light_token_sdk::token::SingleMintParams { + decimals: #decimals, + address_merkle_tree_root_index: __tree_info.root_index, + mint_authority: *self.#authority.to_account_info().key, + compression_address: #pda_ident.to_bytes(), + mint: #pda_ident, + bump: #bump_ident, + freeze_authority: #freeze_authority, + mint_seed_pubkey: #signer_key_ident, + authority_seeds: #authority_seeds_ident, + mint_signer_seeds: Some(#mint_seeds_ident), + }; } - } - None => { - // authority_seeds not provided - authority must be a transaction signer + }) + .collect(); + + // Generate array of SingleMintParams + let param_idents: Vec = (0..mint_count) + .map(|idx| { + let ident = format_ident!("__mint_param_{}", idx); + quote! { #ident } + }) + .collect(); + + // Generate array of mint seed AccountInfos + let mint_seed_account_exprs: Vec = mints + .iter() + .map(|mint| { + let mint_signer = &mint.mint_signer; + quote! { self.#mint_signer.to_account_info() } + }) + .collect(); + + // Generate array of mint AccountInfos + let mint_account_exprs: Vec = mints + .iter() + .map(|mint| { + let field_ident = &mint.field_ident; + quote! { self.#field_ident.to_account_info() } + }) + .collect(); + + // Get rent_payment and write_top_up from first mint (all mints share same params for now) + let rent_payment = quote_option_or(&mints[0].rent_payment, quote! { 16u8 }); + let write_top_up = quote_option_or(&mints[0].write_top_up, quote! { 766u32 }); + + // Authority signer check for mints without authority_seeds + let authority_signer_checks: Vec = mints + .iter() + .filter(|m| m.authority_seeds.is_none()) + .map(|mint| { + let authority = &mint.authority; quote! { - // Verify authority is a signer since authority_seeds was not provided if !self.#authority.to_account_info().is_signer { return Err(anchor_lang::solana_program::program_error::ProgramError::MissingRequiredSignature.into()); } - anchor_lang::solana_program::program::invoke_signed( - &mint_action_ix, - &account_infos, - &[mint_seeds] - )?; } - } - }; + }) + .collect(); - // ------------------------------------------------------------------------- - // Generated code block for mint_action CPI invocation. - // - // Interpolated variables from CpiContextParts (see struct for with/without cases): - // #cpi_setup - defines __output_tree_index when batching with PDAs - // #queue_access - expression to get output queue index - // #data_binding - "let mut" (with CPI) or "let" (without CPI) - // #cpi_chain - adds .with_cpi_context(...) when batching - // #cpi_meta_assignment - sets meta_config.cpi_context when batching - // - // Interpolated variables from #[light_mint(...)] attributes: - // #address_tree_info - tree info (default: params.create_accounts_proof.address_tree_info) - // #mint_signer - field that seeds the mint PDA - // #authority - mint authority field - // #decimals - mint decimals - // #freeze_authority - optional freeze authority (Some(*self.field.key) or None) - // #rent_payment - rent epochs for decompression (default: 2u8) - // #write_top_up - write top-up lamports (default: 0u32) - // #mint_seeds - PDA signer seeds for mint_signer (default: &[] as &[&[u8]]) - // #authority_seeds - PDA signer seeds for authority (optional, if authority is a PDA) - // - // Interpolated variables from infrastructure fields: - // #fee_payer, #ctoken_config, #ctoken_rent_sponsor, - // #light_token_program, #ctoken_cpi_authority, #mint_field_ident - // ------------------------------------------------------------------------- quote! { { - // Step 1: Resolve tree accounts - let __tree_info = &#address_tree_info; - let address_tree = cpi_accounts.get_tree_account_info(__tree_info.address_merkle_tree_pubkey_index as usize)?; - #cpi_setup - let output_queue = cpi_accounts.get_tree_account_info(#queue_access)?; - let __tree_pubkey: solana_pubkey::Pubkey = light_sdk::light_account_checks::AccountInfoTrait::pubkey(address_tree); - - // Step 2: Derive mint PDA from mint_signer - let mint_signer_key = self.#mint_signer.to_account_info().key; - let (mint_pda, _cmint_bump) = light_token_sdk::token::find_mint_address(mint_signer_key); - - // Step 3: Extract proof from instruction params + #output_tree_setup + + // Extract proof from instruction params let __proof: light_token_sdk::CompressedProof = #params_ident.create_accounts_proof.proof.0.clone() .expect("proof is required for mint creation"); - let __freeze_authority: Option = #freeze_authority; - - // Step 4: Build mint instruction data - let compressed_mint_data = light_token_interface::instructions::mint_action::MintInstructionData { - supply: 0, - decimals: #decimals, - metadata: light_token_interface::state::MintMetadata { - version: 3, - mint: mint_pda.to_bytes().into(), - mint_decompressed: false, - mint_signer: mint_signer_key.to_bytes(), - bump: _cmint_bump, - }, - mint_authority: Some((*self.#authority.to_account_info().key).to_bytes().into()), - freeze_authority: __freeze_authority.map(|a| a.to_bytes().into()), - extensions: None, - }; - - // Step 5: Build compressed instruction data with decompress config - #data_binding = light_token_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - __tree_info.root_index, + // Build SingleMintParams for each mint + #(#mint_params_builds)* + + // Array of mint params + let __mint_params: [light_token_sdk::token::SingleMintParams<'_>; #mint_count] = [ + #(#param_idents),* + ]; + + // Array of mint seed AccountInfos + let __mint_seed_accounts: [solana_account_info::AccountInfo<'info>; #mint_count] = [ + #(#mint_seed_account_exprs),* + ]; + + // Array of mint AccountInfos + let __mint_accounts: [solana_account_info::AccountInfo<'info>; #mint_count] = [ + #(#mint_account_exprs),* + ]; + + // Get tree accounts and indices + // Output queue for state (compressed accounts) is at tree index 0 + // State merkle tree index comes from the proof (set by pack_proof_for_mints) + // Address merkle tree index comes from the proof's address_tree_info + let __tree_info = &#params_ident.create_accounts_proof.address_tree_info; + let __output_queue_index: u8 = 0; + let __state_tree_index: u8 = #params_ident.create_accounts_proof.state_tree_index + .ok_or(anchor_lang::prelude::ProgramError::InvalidArgument)?; + let __address_tree_index: u8 = __tree_info.address_merkle_tree_pubkey_index; + let __output_queue = cpi_accounts.get_tree_account_info(__output_queue_index as usize)?; + let __state_merkle_tree = cpi_accounts.get_tree_account_info(__state_tree_index as usize)?; + let __address_tree = cpi_accounts.get_tree_account_info(__address_tree_index as usize)?; + + // Build CreateMintsParams with tree indices + let __create_mints_params = light_token_sdk::token::CreateMintsParams::new( + &__mint_params, __proof, - compressed_mint_data, ) - .with_decompress_mint(light_token_interface::instructions::mint_action::DecompressMintAction { - rent_payment: #rent_payment, - write_top_up: #write_top_up, - }) - #cpi_chain; - - // Step 6: Build account metas for CPI - let mut meta_config = light_token_sdk::compressed_token::mint_action::MintActionMetaConfig::new_create_mint( - *self.#fee_payer.to_account_info().key, - *self.#authority.to_account_info().key, - *mint_signer_key, - __tree_pubkey, - *output_queue.key, - ) - .with_compressible_mint( - mint_pda, - *self.#ctoken_config.to_account_info().key, - *self.#ctoken_rent_sponsor.to_account_info().key, - ); - - #cpi_meta_assignment - - let account_metas = meta_config.to_account_metas(); - - // Step 7: Serialize instruction data - use light_compressed_account::instruction_data::traits::LightInstructionData; - let ix_data = instruction_data.data() - .map_err(|_| light_sdk::error::LightSdkError::Borsh)?; - - // Step 8: Build the CPI instruction - let mint_action_ix = anchor_lang::solana_program::instruction::Instruction { - program_id: solana_pubkey::Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID), - accounts: account_metas, - data: ix_data, - }; - - // Step 9: Collect account infos for CPI - let mut account_infos = cpi_accounts.to_account_infos(); - account_infos.push(self.#light_token_program.to_account_info()); - account_infos.push(self.#ctoken_cpi_authority.to_account_info()); - account_infos.push(self.#mint_field_ident.to_account_info()); - account_infos.push(self.#ctoken_config.to_account_info()); - account_infos.push(self.#ctoken_rent_sponsor.to_account_info()); - account_infos.push(self.#authority.to_account_info()); - account_infos.push(self.#mint_signer.to_account_info()); - account_infos.push(self.#fee_payer.to_account_info()); - - // Step 10: Invoke CPI with signer seeds - let mint_seeds: &[&[u8]] = #mint_seeds; - #invoke_signed_call + .with_rent_payment(#rent_payment) + .with_write_top_up(#write_top_up) // TODO: discuss to allow a different one per mint. + .with_cpi_context_offset(#cpi_context_offset) + .with_output_queue_index(__output_queue_index) + .with_address_tree_index(__address_tree_index) + .with_state_tree_index(__state_tree_index); + + // Check authority signers for mints without authority_seeds + #(#authority_signer_checks)* + + // Build and invoke CreateMintsCpi + // Seeds are extracted from SingleMintParams internally + light_token_sdk::token::CreateMintsCpi { + mint_seed_accounts: &__mint_seed_accounts, + payer: self.#fee_payer.to_account_info(), + address_tree: __address_tree.clone(), + output_queue: __output_queue.clone(), + state_merkle_tree: __state_merkle_tree.clone(), + compressible_config: self.#ctoken_config.to_account_info(), + mints: &__mint_accounts, + rent_sponsor: self.#ctoken_rent_sponsor.to_account_info(), + system_accounts: light_token_sdk::token::SystemAccountInfos { + light_system_program: cpi_accounts.light_system_program()?.clone(), + cpi_authority_pda: self.#ctoken_cpi_authority.to_account_info(), + registered_program_pda: cpi_accounts.registered_program_pda()?.clone(), + account_compression_authority: cpi_accounts.account_compression_authority()?.clone(), + account_compression_program: cpi_accounts.account_compression_program()?.clone(), + system_program: cpi_accounts.system_program()?.clone(), + }, + cpi_context_account: cpi_accounts.cpi_context()?.clone(), + params: __create_mints_params, + } + .invoke()?; } } } diff --git a/sdk-libs/macros/src/rentfree/accounts/parse.rs b/sdk-libs/macros/src/rentfree/accounts/parse.rs index fcf1932847..df0dd01142 100644 --- a/sdk-libs/macros/src/rentfree/accounts/parse.rs +++ b/sdk-libs/macros/src/rentfree/accounts/parse.rs @@ -9,9 +9,9 @@ use syn::{ // Import LightMintField and parsing from light_mint module use super::light_mint::{parse_light_mint_attr, LightMintField}; -use crate::rentfree::shared_utils::MetaExpr; // Import shared types -pub(super) use crate::rentfree::traits::seed_extraction::extract_account_inner_type; +pub(super) use crate::rentfree::account::seed_extraction::extract_account_inner_type; +use crate::rentfree::shared_utils::MetaExpr; // ============================================================================ // Infrastructure Field Classification diff --git a/sdk-libs/macros/src/rentfree/mod.rs b/sdk-libs/macros/src/rentfree/mod.rs index e10bea0ab6..05b0f9b73e 100644 --- a/sdk-libs/macros/src/rentfree/mod.rs +++ b/sdk-libs/macros/src/rentfree/mod.rs @@ -3,10 +3,10 @@ //! This module organizes all rent-free related macros: //! - `program/` - `#[rentfree_program]` attribute macro for program-level auto-discovery //! - `accounts/` - `#[derive(RentFree)]` derive macro for Accounts structs -//! - `traits/` - Shared trait derive macros (Compressible, Pack, HasCompressionInfo, etc.) +//! - `account/` - Trait derive macros for account data structs (Compressible, Pack, HasCompressionInfo, etc.) //! - `shared_utils` - Common utilities (constant detection, identifier extraction) +pub mod account; pub mod accounts; pub mod program; pub mod shared_utils; -pub mod traits; diff --git a/sdk-libs/macros/src/rentfree/program/crate_context.rs b/sdk-libs/macros/src/rentfree/program/crate_context.rs index db9191f28d..a46d18e237 100644 --- a/sdk-libs/macros/src/rentfree/program/crate_context.rs +++ b/sdk-libs/macros/src/rentfree/program/crate_context.rs @@ -70,6 +70,34 @@ impl CrateContext { pub fn module(&self, path: &str) -> Option<&ParsedModule> { self.modules.get(path) } + + /// Get the field names of a struct by its type. + /// + /// The type can be a simple identifier (e.g., "SinglePubkeyRecord") or + /// a qualified path. Returns None if the struct is not found. + pub fn get_struct_fields(&self, type_name: &syn::Type) -> Option> { + // Extract the struct name from the type path + let struct_name = match type_name { + syn::Type::Path(type_path) => type_path.path.segments.last()?.ident.to_string(), + _ => return None, + }; + + // Find the struct by name + for item_struct in self.structs() { + if item_struct.ident == struct_name { + // Extract field names from the struct + if let syn::Fields::Named(named_fields) = &item_struct.fields { + let field_names: Vec = named_fields + .named + .iter() + .filter_map(|f| f.ident.as_ref().map(|i| i.to_string())) + .collect(); + return Some(field_names); + } + } + } + None + } } /// A parsed module containing its items. diff --git a/sdk-libs/macros/src/rentfree/program/decompress.rs b/sdk-libs/macros/src/rentfree/program/decompress.rs index f9f129820d..a2148e7fc0 100644 --- a/sdk-libs/macros/src/rentfree/program/decompress.rs +++ b/sdk-libs/macros/src/rentfree/program/decompress.rs @@ -23,7 +23,7 @@ pub fn generate_decompress_context_impl( let lifetime: syn::Lifetime = syn::parse_quote!('info); let trait_impl = - crate::rentfree::traits::decompress_context::generate_decompress_context_trait_impl( + crate::rentfree::account::decompress_context::generate_decompress_context_trait_impl( pda_ctx_seeds, token_variant_ident, lifetime, @@ -60,7 +60,7 @@ pub fn generate_process_decompress_accounts_idempotent() -> Result system_accounts_offset, LIGHT_CPI_SIGNER, &crate::ID, - std::option::Option::None::<&()>, + None, ) .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) } @@ -156,11 +156,24 @@ pub fn generate_decompress_accounts_struct(variant: InstructionVariant) -> Resul /// Generate PDA seed derivation that uses CtxSeeds struct instead of DecompressAccountsIdempotent. /// Maps ctx.field -> ctx_seeds.field (direct Pubkey access, no Option unwrapping needed) +/// Only maps data.field -> self.field if the field exists on the state struct. +/// For params-only fields, uses seed_params.field instead of skipping. #[inline(never)] fn generate_pda_seed_derivation_for_trait_with_ctx_seeds( spec: &TokenSeedSpec, ctx_seed_fields: &[syn::Ident], + state_field_names: &std::collections::HashSet, + params_only_fields: &[(syn::Ident, syn::Type, bool)], ) -> Result { + // Build a lookup for params-only field names + let params_only_names: std::collections::HashSet = params_only_fields + .iter() + .map(|(name, _, _)| name.to_string()) + .collect(); + let params_only_has_conversion: std::collections::HashMap = params_only_fields + .iter() + .map(|(name, _, has_conv)| (name.to_string(), *has_conv)) + .collect(); let mut bindings: Vec = Vec::new(); let mut seed_refs = Vec::new(); @@ -196,9 +209,50 @@ fn generate_pda_seed_derivation_for_trait_with_ctx_seeds( } } + // Check if this is a data.field expression where the field doesn't exist on state + // If so, use seed_params.field instead of skipping + if let Some(field_name) = get_params_only_field_name(expr, state_field_names) { + if params_only_names.contains(&field_name) { + let field_ident = + syn::Ident::new(&field_name, proc_macro2::Span::call_site()); + let binding_name = + syn::Ident::new(&format!("seed_{}", i), proc_macro2::Span::call_site()); + + // Check if this field has a conversion (to_le_bytes, to_be_bytes) + let has_conversion = params_only_has_conversion + .get(&field_name) + .copied() + .unwrap_or(false); + + if has_conversion { + // u64 field with to_le_bytes conversion + // Must bind bytes to a variable to avoid temporary value dropped while borrowed + let bytes_binding_name = syn::Ident::new( + &format!("{}_bytes", binding_name), + proc_macro2::Span::call_site(), + ); + bindings.push(quote! { + let #binding_name = seed_params.#field_ident + .ok_or(solana_program_error::ProgramError::InvalidAccountData)?; + let #bytes_binding_name = #binding_name.to_le_bytes(); + }); + seed_refs.push(quote! { #bytes_binding_name.as_ref() }); + } else { + // Pubkey field + bindings.push(quote! { + let #binding_name = seed_params.#field_ident + .ok_or(solana_program_error::ProgramError::InvalidAccountData)?; + }); + seed_refs.push(quote! { #binding_name.as_ref() }); + } + continue; + } + } + let binding_name = syn::Ident::new(&format!("seed_{}", i), proc_macro2::Span::call_site()); - let mapped_expr = transform_expr_for_ctx_seeds(expr, &ctx_field_names); + let mapped_expr = + transform_expr_for_ctx_seeds(expr, &ctx_field_names, state_field_names); bindings.push(quote! { let #binding_name = #mapped_expr; }); @@ -217,11 +271,55 @@ fn generate_pda_seed_derivation_for_trait_with_ctx_seeds( #( seeds_vec.push(seeds[#indices].to_vec()); )* - seeds_vec.push(vec![bump]); + // Avoid vec![bump] macro which expands to box_new allocation + { + let mut bump_vec = Vec::with_capacity(1); + bump_vec.push(bump); + seeds_vec.push(bump_vec); + } Ok((seeds_vec, pda)) }) } +/// Check if a seed expression is a params-only seed (data.field where field doesn't exist on state) +#[allow(dead_code)] +fn is_params_only_seed( + expr: &syn::Expr, + state_field_names: &std::collections::HashSet, +) -> bool { + get_params_only_field_name(expr, state_field_names).is_some() +} + +/// Get the field name from a params-only seed expression. +/// Returns Some(field_name) if the expression is a data.field where field doesn't exist on state. +fn get_params_only_field_name( + expr: &syn::Expr, + state_field_names: &std::collections::HashSet, +) -> Option { + use crate::rentfree::shared_utils::is_base_path; + + match expr { + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + if is_base_path(&field_expr.base, "data") { + let name = field_name.to_string(); + if !state_field_names.contains(&name) { + return Some(name); + } + } + } + None + } + syn::Expr::MethodCall(method_call) => { + get_params_only_field_name(&method_call.receiver, state_field_names) + } + syn::Expr::Reference(ref_expr) => { + get_params_only_field_name(&ref_expr.expr, state_field_names) + } + _ => None, + } +} + // ============================================================================= // PDA SEED PROVIDER IMPLS // ============================================================================= @@ -285,24 +383,49 @@ pub fn generate_pda_seed_provider_impls( } }; - let seed_derivation = - generate_pda_seed_derivation_for_trait_with_ctx_seeds(spec, ctx_fields)?; + let params_only_fields = &ctx_info.params_only_seed_fields; + let seed_derivation = generate_pda_seed_derivation_for_trait_with_ctx_seeds( + spec, + ctx_fields, + &ctx_info.state_field_names, + params_only_fields, + )?; // Generate impl for inner_type, but use variant-based struct name - results.push(quote! { - #ctx_seeds_struct - - impl light_sdk::compressible::PdaSeedDerivation<#ctx_seeds_struct_name, ()> for #inner_type { - fn derive_pda_seeds_with_accounts( - &self, - program_id: &solana_pubkey::Pubkey, - ctx_seeds: &#ctx_seeds_struct_name, - _seed_params: &(), - ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { - #seed_derivation + // Use SeedParams if there are params-only fields, otherwise use () + let has_params_only = !params_only_fields.is_empty(); + let seed_params_impl = if has_params_only { + quote! { + #ctx_seeds_struct + + impl light_sdk::compressible::PdaSeedDerivation<#ctx_seeds_struct_name, SeedParams> for #inner_type { + fn derive_pda_seeds_with_accounts( + &self, + program_id: &solana_pubkey::Pubkey, + ctx_seeds: &#ctx_seeds_struct_name, + seed_params: &SeedParams, + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { + #seed_derivation + } } } - }); + } else { + quote! { + #ctx_seeds_struct + + impl light_sdk::compressible::PdaSeedDerivation<#ctx_seeds_struct_name, SeedParams> for #inner_type { + fn derive_pda_seeds_with_accounts( + &self, + program_id: &solana_pubkey::Pubkey, + ctx_seeds: &#ctx_seeds_struct_name, + _seed_params: &SeedParams, + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { + #seed_derivation + } + } + } + }; + results.push(seed_params_impl); } Ok(results) diff --git a/sdk-libs/macros/src/rentfree/program/expr_traversal.rs b/sdk-libs/macros/src/rentfree/program/expr_traversal.rs index 59866d94b0..99ee69b6a3 100644 --- a/sdk-libs/macros/src/rentfree/program/expr_traversal.rs +++ b/sdk-libs/macros/src/rentfree/program/expr_traversal.rs @@ -16,10 +16,26 @@ use crate::rentfree::shared_utils::is_base_path; /// Transform expressions by replacing field access patterns. /// /// Used for converting: -/// - `data.field` -> `self.field` +/// - `data.field` -> `self.field` (only if field exists in state_field_names) /// - `ctx.field` -> `ctx_seeds.field` (if field is in ctx_field_names) /// - `ctx.accounts.field` -> `ctx_seeds.field` -pub fn transform_expr_for_ctx_seeds(expr: &Expr, ctx_field_names: &HashSet) -> Expr { +/// +/// For `data.field` where field is NOT in state_field_names, the expression +/// is left unchanged (which will cause a compile error, alerting the user +/// that this field is not supported for decompression seed derivation). +pub fn transform_expr_for_ctx_seeds( + expr: &Expr, + ctx_field_names: &HashSet, + state_field_names: &HashSet, +) -> Expr { + transform_expr_internal(expr, ctx_field_names, state_field_names) +} + +fn transform_expr_internal( + expr: &Expr, + ctx_field_names: &HashSet, + state_field_names: &HashSet, +) -> Expr { match expr { Expr::Field(field_expr) => { let Some(syn::Member::Named(field_name)) = Some(&field_expr.member) else { @@ -35,10 +51,18 @@ pub fn transform_expr_for_ctx_seeds(expr: &Expr, ctx_field_names: &HashSet self.field or ctx.field -> ctx_seeds.field + // Check for data.field -> self.field (only if field exists on state struct) if is_base_path(&field_expr.base, "data") { - return syn::parse_quote! { self.#field_name }; + let field_str = field_name.to_string(); + if state_field_names.contains(&field_str) { + return syn::parse_quote! { self.#field_name }; + } + // Field not on state struct - leave unchanged (will cause compile error + // unless handled elsewhere). This handles params-only seeds. + return expr.clone(); } + + // Check for ctx.field -> ctx_seeds.field if is_base_path(&field_expr.base, "ctx") && ctx_field_names.contains(&field_name.to_string()) { @@ -49,14 +73,15 @@ pub fn transform_expr_for_ctx_seeds(expr: &Expr, ctx_field_names: &HashSet { let mut new_call = method_call.clone(); - new_call.receiver = Box::new(transform_expr_for_ctx_seeds( + new_call.receiver = Box::new(transform_expr_internal( &method_call.receiver, ctx_field_names, + state_field_names, )); new_call.args = method_call .args .iter() - .map(|a| transform_expr_for_ctx_seeds(a, ctx_field_names)) + .map(|a| transform_expr_internal(a, ctx_field_names, state_field_names)) .collect(); Expr::MethodCall(new_call) } @@ -65,15 +90,16 @@ pub fn transform_expr_for_ctx_seeds(expr: &Expr, ctx_field_names: &HashSet { let mut new_ref = ref_expr.clone(); - new_ref.expr = Box::new(transform_expr_for_ctx_seeds( + new_ref.expr = Box::new(transform_expr_internal( &ref_expr.expr, ctx_field_names, + state_field_names, )); Expr::Reference(new_ref) } diff --git a/sdk-libs/macros/src/rentfree/program/instructions.rs b/sdk-libs/macros/src/rentfree/program/instructions.rs index 87af7c562b..1ca6f24e1b 100644 --- a/sdk-libs/macros/src/rentfree/program/instructions.rs +++ b/sdk-libs/macros/src/rentfree/program/instructions.rs @@ -43,6 +43,7 @@ fn codegen( pda_seeds: Option>, token_seeds: Option>, instruction_data: Vec, + crate_ctx: &super::crate_context::CrateContext, ) -> Result { let size_validation_checks = validate_compressed_account_sizes(&account_types)?; @@ -59,10 +60,10 @@ fn codegen( if !token_seed_specs.is_empty() { super::variant_enum::generate_ctoken_account_variant_enum(token_seed_specs)? } else { - crate::rentfree::traits::utils::generate_empty_ctoken_enum() + crate::rentfree::account::utils::generate_empty_ctoken_enum() } } else { - crate::rentfree::traits::utils::generate_empty_ctoken_enum() + crate::rentfree::account::utils::generate_empty_ctoken_enum() }; if let Some(ref token_seed_specs) = token_seeds { @@ -89,7 +90,23 @@ fn codegen( .inner_type .clone() .unwrap_or_else(|| ident_to_type(&spec.variant)); - PdaCtxSeedInfo::new(spec.variant.clone(), inner_type, ctx_fields) + + // Look up the state struct's field names from CrateContext + let state_field_names: std::collections::HashSet = crate_ctx + .get_struct_fields(&inner_type) + .map(|fields| fields.into_iter().collect()) + .unwrap_or_default(); + + // Extract params-only seed fields (data.* fields that don't exist on state) + let params_only_seed_fields = crate::rentfree::account::seed_extraction::get_params_only_seed_fields_from_spec(spec, &state_field_names); + + PdaCtxSeedInfo::with_state_fields( + spec.variant.clone(), + inner_type, + ctx_fields, + state_field_names, + params_only_seed_fields, + ) }) .collect() }) @@ -98,9 +115,54 @@ fn codegen( let enum_and_traits = super::variant_enum::compressed_account_variant_with_ctx_seeds(&pda_ctx_seeds)?; - let seed_params_struct = quote! { - #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize, Clone, Debug, Default)] - pub struct SeedParams; + // Collect all unique params-only seed fields across all variants for SeedParams struct + // Use BTreeMap for deterministic ordering + let mut all_params_only_fields: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for ctx_info in &pda_ctx_seeds { + for (field_name, field_type, _) in &ctx_info.params_only_seed_fields { + let field_str = field_name.to_string(); + all_params_only_fields + .entry(field_str) + .or_insert_with(|| field_type.clone()); + } + } + + let seed_params_struct = if all_params_only_fields.is_empty() { + quote! { + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize, Clone, Debug, Default)] + pub struct SeedParams; + } + } else { + // Collect into Vec for consistent ordering between field declarations and Default impl + let sorted_fields: Vec<_> = all_params_only_fields.iter().collect(); + let seed_param_fields: Vec<_> = sorted_fields + .iter() + .map(|(name, ty)| { + let field_ident = format_ident!("{}", name); + quote! { pub #field_ident: Option<#ty> } + }) + .collect(); + let seed_param_defaults: Vec<_> = sorted_fields + .iter() + .map(|(name, _)| { + let field_ident = format_ident!("{}", name); + quote! { #field_ident: None } + }) + .collect(); + quote! { + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize, Clone, Debug)] + pub struct SeedParams { + #(#seed_param_fields,)* + } + impl Default for SeedParams { + fn default() -> Self { + Self { + #(#seed_param_defaults,)* + } + } + } + } }; let instruction_data_types: std::collections::HashMap = instruction_data @@ -122,6 +184,7 @@ fn codegen( let seeds_struct_name = format_ident!("{}Seeds", variant_name); let constructor_name = format_ident!("{}", to_snake_case(&variant_name.to_string())); let ctx_fields = &ctx_info.ctx_seed_fields; + let params_only_fields = &ctx_info.params_only_seed_fields; let ctx_field_decls: Vec<_> = ctx_fields.iter().map(|field| { quote! { pub #field: solana_pubkey::Pubkey } }).collect(); @@ -132,13 +195,23 @@ fn codegen( quote! { pub #field: #ty } }) }).collect(); - let data_verifications: Vec<_> = data_fields.iter().map(|field| { - quote! { + // Only generate verifications for data fields that exist on the state struct + let data_verifications: Vec<_> = data_fields.iter().filter_map(|field| { + let field_str = field.to_string(); + // Skip fields that don't exist on the state struct (e.g., params-only seeds) + if !ctx_info.state_field_names.contains(&field_str) { + return None; + } + Some(quote! { if data.#field != seeds.#field { return std::result::Result::Err(RentFreeInstructionError::SeedMismatch.into()); } - } + }) }).collect(); + + // Extract params-only field names from ctx_info for variant construction + let params_only_field_names: Vec<_> = params_only_fields.iter().map(|(f, _, _)| f).collect(); + quote! { #[derive(Clone, Debug)] pub struct #seeds_struct_name { @@ -157,9 +230,11 @@ fn codegen( #(#data_verifications)* // Use variant_name for the enum variant + // Include ctx fields and params-only fields from seeds std::result::Result::Ok(Self::#variant_name { data, #(#ctx_fields: seeds.#ctx_fields,)* + #(#params_only_field_names: seeds.#params_only_field_names,)* }) } } @@ -316,9 +391,11 @@ fn codegen( &instruction_data, )?; - // Insert SeedParams struct - let seed_params_item: Item = syn::parse2(seed_params_struct)?; - content.1.push(seed_params_item); + // Insert SeedParams struct and impl + let seed_params_file: syn::File = syn::parse2(seed_params_struct)?; + for item in seed_params_file.items { + content.1.push(item); + } // Insert XxxSeeds structs and RentFreeAccountVariant constructors for seeds_tokens in seeds_structs_and_constructors.into_iter() { @@ -405,7 +482,7 @@ fn codegen( #[inline(never)] pub fn rentfree_program_impl(_args: TokenStream, mut module: ItemMod) -> Result { use super::crate_context::CrateContext; - use crate::rentfree::traits::seed_extraction::{ + use crate::rentfree::account::seed_extraction::{ extract_from_accounts_struct, get_data_fields, ExtractedSeedSpec, ExtractedTokenSpec, }; @@ -423,7 +500,10 @@ pub fn rentfree_program_impl(_args: TokenStream, mut module: ItemMod) -> Result< for item_struct in crate_ctx.structs_with_derive("Accounts") { if let Some(info) = extract_from_accounts_struct(item_struct)? { - if !info.pda_fields.is_empty() || !info.token_fields.is_empty() { + if !info.pda_fields.is_empty() + || !info.token_fields.is_empty() + || info.has_light_mint_fields + { rentfree_struct_names.insert(info.struct_name.to_string()); pda_specs.extend(info.pda_fields); token_specs.extend(info.token_fields); @@ -540,5 +620,6 @@ pub fn rentfree_program_impl(_args: TokenStream, mut module: ItemMod) -> Result< pda_seeds, token_seeds, found_data_fields, + &crate_ctx, ) } diff --git a/sdk-libs/macros/src/rentfree/program/parsing.rs b/sdk-libs/macros/src/rentfree/program/parsing.rs index c5cf063aa8..59b73d2a06 100644 --- a/sdk-libs/macros/src/rentfree/program/parsing.rs +++ b/sdk-libs/macros/src/rentfree/program/parsing.rs @@ -283,9 +283,9 @@ pub fn extract_data_seed_fields( /// Convert ClassifiedSeed to SeedElement (Punctuated) pub fn convert_classified_to_seed_elements( - seeds: &[crate::rentfree::traits::seed_extraction::ClassifiedSeed], + seeds: &[crate::rentfree::account::seed_extraction::ClassifiedSeed], ) -> Punctuated { - use crate::rentfree::traits::seed_extraction::ClassifiedSeed; + use crate::rentfree::account::seed_extraction::ClassifiedSeed; let mut result = Punctuated::new(); for seed in seeds { @@ -338,7 +338,7 @@ pub fn convert_classified_to_seed_elements( } pub fn convert_classified_to_seed_elements_vec( - seeds: &[crate::rentfree::traits::seed_extraction::ClassifiedSeed], + seeds: &[crate::rentfree::account::seed_extraction::ClassifiedSeed], ) -> Vec { convert_classified_to_seed_elements(seeds) .into_iter() diff --git a/sdk-libs/macros/src/rentfree/program/variant_enum.rs b/sdk-libs/macros/src/rentfree/program/variant_enum.rs index d0d5659d15..f986d649c1 100644 --- a/sdk-libs/macros/src/rentfree/program/variant_enum.rs +++ b/sdk-libs/macros/src/rentfree/program/variant_enum.rs @@ -16,14 +16,27 @@ pub struct PdaCtxSeedInfo { pub inner_type: Type, /// Field names from ctx.accounts.XXX references in seeds pub ctx_seed_fields: Vec, + /// Field names that exist on the state struct (for filtering data.* seeds) + pub state_field_names: std::collections::HashSet, + /// Params-only seed fields (name, type, has_conversion) - seeds from params.* that don't exist on state + /// The bool indicates whether a conversion method like to_le_bytes() is applied + pub params_only_seed_fields: Vec<(Ident, Type, bool)>, } impl PdaCtxSeedInfo { - pub fn new(variant_name: Ident, inner_type: Type, ctx_seed_fields: Vec) -> Self { + pub fn with_state_fields( + variant_name: Ident, + inner_type: Type, + ctx_seed_fields: Vec, + state_field_names: std::collections::HashSet, + params_only_seed_fields: Vec<(Ident, Type, bool)>, + ) -> Self { Self { variant_name, inner_type, ctx_seed_fields, + state_field_names, + params_only_seed_fields, } } } @@ -40,7 +53,7 @@ pub fn compressed_account_variant_with_ctx_seeds( )); } - // Phase 2: Generate struct variants with ctx.* seed fields + // Phase 2: Generate struct variants with ctx.* seed fields and params-only seed fields // Uses variant_name for enum variant naming, inner_type for data field types let account_variants = pda_ctx_seeds.iter().map(|info| { let variant_name = &info.variant_name; @@ -51,22 +64,30 @@ pub fn compressed_account_variant_with_ctx_seeds( let packed_inner_type = make_packed_type(&info.inner_type).expect("inner_type should be a valid type path"); let ctx_fields = &info.ctx_seed_fields; + let params_only_fields = &info.params_only_seed_fields; - // Unpacked variant: Pubkey fields for ctx.* seeds + // Unpacked variant: Pubkey fields for ctx.* seeds + params-only seed values // Note: Use bare Pubkey which is in scope via `use anchor_lang::prelude::*` let unpacked_ctx_fields = ctx_fields.iter().map(|field| { quote! { #field: Pubkey } }); + let unpacked_params_fields = params_only_fields.iter().map(|(field, ty, _)| { + quote! { #field: #ty } + }); - // Packed variant: u8 index fields for ctx.* seeds + // Packed variant: u8 index fields for ctx.* seeds + params-only seed values (same type) let packed_ctx_fields = ctx_fields.iter().map(|field| { let idx_field = format_ident!("{}_idx", field); quote! { #idx_field: u8 } }); + // Params-only fields keep the same type in packed variant (not indices) + let packed_params_fields = params_only_fields.iter().map(|(field, ty, _)| { + quote! { #field: #ty } + }); quote! { - #variant_name { data: #inner_type, #(#unpacked_ctx_fields,)* }, - #packed_variant_name { data: #packed_inner_type, #(#packed_ctx_fields,)* }, + #variant_name { data: #inner_type, #(#unpacked_ctx_fields,)* #(#unpacked_params_fields,)* }, + #packed_variant_name { data: #packed_inner_type, #(#packed_ctx_fields,)* #(#packed_params_fields,)* }, } }); @@ -85,13 +106,17 @@ pub fn compressed_account_variant_with_ctx_seeds( let first_variant = &first.variant_name; let first_type = qualify_type_with_crate(&first.inner_type); let first_ctx_fields = &first.ctx_seed_fields; + let first_params_only_fields = &first.params_only_seed_fields; let first_default_ctx_fields = first_ctx_fields.iter().map(|field| { quote! { #field: Pubkey::default() } }); + let first_default_params_fields = first_params_only_fields.iter().map(|(field, ty, _)| { + quote! { #field: <#ty as Default>::default() } + }); let default_impl = quote! { impl Default for RentFreeAccountVariant { fn default() -> Self { - Self::#first_variant { data: #first_type::default(), #(#first_default_ctx_fields,)* } + Self::#first_variant { data: #first_type::default(), #(#first_default_ctx_fields,)* #(#first_default_params_fields,)* } } } }; @@ -223,15 +248,28 @@ pub fn compressed_account_variant_with_ctx_seeds( } }; - // Phase 2: Pack/Unpack with ctx seed fields + // Phase 2: Pack/Unpack with ctx seed fields and params-only seed fields let pack_match_arms: Vec<_> = pda_ctx_seeds.iter().map(|info| { let variant_name = &info.variant_name; let inner_type = qualify_type_with_crate(&info.inner_type); let packed_variant_name = format_ident!("Packed{}", variant_name); let ctx_fields = &info.ctx_seed_fields; + let params_only_fields = &info.params_only_seed_fields; - if ctx_fields.is_empty() { - // No ctx seeds - simple pack + // Collect ctx field names and their idx equivalents + let ctx_field_names: Vec<_> = ctx_fields.iter().collect(); + let idx_field_names: Vec<_> = ctx_fields.iter().map(|f| format_ident!("{}_idx", f)).collect(); + let pack_ctx_seeds: Vec<_> = ctx_fields.iter().map(|field| { + let idx_field = format_ident!("{}_idx", field); + // Dereference because we're matching on &self, so field is &Pubkey + quote! { let #idx_field = remaining_accounts.insert_or_get(*#field); } + }).collect(); + + // Collect params-only field names (these are copied directly, not indexed) + let params_field_names: Vec<_> = params_only_fields.iter().map(|(f, _, _)| f).collect(); + + // If no ctx seeds and no params-only fields - simple pack + if ctx_fields.is_empty() && params_only_fields.is_empty() { quote! { RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), RentFreeAccountVariant::#variant_name { data, .. } => RentFreeAccountVariant::#packed_variant_name { @@ -239,22 +277,15 @@ pub fn compressed_account_variant_with_ctx_seeds( }, } } else { - // Has ctx seeds - pack data and ctx seed pubkeys - let field_names: Vec<_> = ctx_fields.iter().collect(); - let idx_field_names: Vec<_> = ctx_fields.iter().map(|f| format_ident!("{}_idx", f)).collect(); - let pack_ctx_seeds: Vec<_> = ctx_fields.iter().map(|field| { - let idx_field = format_ident!("{}_idx", field); - // Dereference because we're matching on &self, so field is &Pubkey - quote! { let #idx_field = remaining_accounts.insert_or_get(*#field); } - }).collect(); - + // Has ctx seeds and/or params-only fields - pack data, ctx seed pubkeys, and copy params-only values quote! { RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), - RentFreeAccountVariant::#variant_name { data, #(#field_names,)* .. } => { + RentFreeAccountVariant::#variant_name { data, #(#ctx_field_names,)* #(#params_field_names,)* .. } => { #(#pack_ctx_seeds)* RentFreeAccountVariant::#packed_variant_name { data: <#inner_type as light_sdk::compressible::Pack>::pack(data, remaining_accounts), #(#idx_field_names,)* + #(#params_field_names: *#params_field_names,)* } }, } @@ -286,9 +317,26 @@ pub fn compressed_account_variant_with_ctx_seeds( let packed_inner_type = make_packed_type(inner_type) .expect("inner_type should be a valid type path"); let ctx_fields = &info.ctx_seed_fields; + let params_only_fields = &info.params_only_seed_fields; - if ctx_fields.is_empty() { - // No ctx seeds - simple unpack + // Collect ctx field names and their idx equivalents + let idx_field_names: Vec<_> = ctx_fields.iter().map(|f| format_ident!("{}_idx", f)).collect(); + let ctx_field_names: Vec<_> = ctx_fields.iter().collect(); + let unpack_ctx_seeds: Vec<_> = ctx_fields.iter().map(|field| { + let idx_field = format_ident!("{}_idx", field); + quote! { + let #field = *remaining_accounts + .get(*#idx_field as usize) + .ok_or(solana_program_error::ProgramError::InvalidAccountData)? + .key; + } + }).collect(); + + // Collect params-only field names (these are copied directly, not resolved from indices) + let params_field_names: Vec<_> = params_only_fields.iter().map(|(f, _, _)| f).collect(); + + // If no ctx seeds and no params-only fields - simple unpack + if ctx_fields.is_empty() && params_only_fields.is_empty() { quote! { RentFreeAccountVariant::#packed_variant_name { data, .. } => Ok(RentFreeAccountVariant::#variant_name { data: <#packed_inner_type as light_sdk::compressible::Unpack>::unpack(data, remaining_accounts)?, @@ -296,25 +344,14 @@ pub fn compressed_account_variant_with_ctx_seeds( RentFreeAccountVariant::#variant_name { .. } => unreachable!(), } } else { - // Has ctx seeds - unpack data and resolve ctx seed pubkeys from indices - let idx_field_names: Vec<_> = ctx_fields.iter().map(|f| format_ident!("{}_idx", f)).collect(); - let field_names: Vec<_> = ctx_fields.iter().collect(); - let unpack_ctx_seeds: Vec<_> = ctx_fields.iter().map(|field| { - let idx_field = format_ident!("{}_idx", field); - quote! { - let #field = *remaining_accounts - .get(*#idx_field as usize) - .ok_or(solana_program_error::ProgramError::InvalidAccountData)? - .key; - } - }).collect(); - + // Has ctx seeds and/or params-only fields - unpack data, resolve ctx seed pubkeys, and copy params-only values quote! { - RentFreeAccountVariant::#packed_variant_name { data, #(#idx_field_names,)* .. } => { + RentFreeAccountVariant::#packed_variant_name { data, #(#idx_field_names,)* #(#params_field_names,)* .. } => { #(#unpack_ctx_seeds)* Ok(RentFreeAccountVariant::#variant_name { data: <#packed_inner_type as light_sdk::compressible::Unpack>::unpack(data, remaining_accounts)?, - #(#field_names,)* + #(#ctx_field_names,)* + #(#params_field_names: *#params_field_names,)* }) }, RentFreeAccountVariant::#variant_name { .. } => unreachable!(), @@ -332,7 +369,10 @@ pub fn compressed_account_variant_with_ctx_seeds( ) -> std::result::Result { match self { #(#unpack_match_arms)* - Self::PackedCTokenData(_data) => Ok(self.clone()), + Self::PackedCTokenData(_) => { + // PackedCTokenData is handled separately in collect_pda_and_token + unreachable!("PackedCTokenData should not be unpacked through Unpack trait") + } Self::CTokenData(_data) => unreachable!(), } } diff --git a/sdk-libs/macros/src/rentfree/program/visitors.rs b/sdk-libs/macros/src/rentfree/program/visitors.rs index 1b4528d27a..f27a46a0d2 100644 --- a/sdk-libs/macros/src/rentfree/program/visitors.rs +++ b/sdk-libs/macros/src/rentfree/program/visitors.rs @@ -19,7 +19,7 @@ use syn::{ }; use super::instructions::{InstructionDataSpec, SeedElement}; -use crate::rentfree::{shared_utils::is_constant_identifier, traits::utils::is_pubkey_type}; +use crate::rentfree::{account::utils::is_pubkey_type, shared_utils::is_constant_identifier}; /// Visitor that extracts field names matching ctx.field, ctx.accounts.field, or data.field patterns. /// diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index bffe842e21..8cbab69efc 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -2150,21 +2150,15 @@ impl TestIndexer { { let compressed_accounts = hashes; - if compressed_accounts.is_some() - && ![1usize, 2usize, 3usize, 4usize, 8usize] - .contains(&compressed_accounts.as_ref().unwrap().len()) - { + if compressed_accounts.is_some() && compressed_accounts.as_ref().unwrap().len() > 8 { return Err(IndexerError::CustomError(format!( - "compressed_accounts must be of length 1, 2, 3, 4 or 8 != {}", + "compressed_accounts must be of length <= 8, got {}", compressed_accounts.unwrap().len() ))); } - if new_addresses.is_some() - && ![1usize, 2usize, 3usize, 4usize, 8usize] - .contains(&new_addresses.as_ref().unwrap().len()) - { + if new_addresses.is_some() && new_addresses.as_ref().unwrap().len() > 8 { return Err(IndexerError::CustomError(format!( - "new_addresses must be of length 1, 2, 3, 4 or 8 != {}", + "new_addresses must be of length <= 8, got {}", new_addresses.unwrap().len() ))); } diff --git a/sdk-libs/sdk-types/src/cpi_accounts/v2.rs b/sdk-libs/sdk-types/src/cpi_accounts/v2.rs index 46af83683b..8773961509 100644 --- a/sdk-libs/sdk-types/src/cpi_accounts/v2.rs +++ b/sdk-libs/sdk-types/src/cpi_accounts/v2.rs @@ -88,6 +88,13 @@ impl<'a, T: AccountInfoTrait + Clone> CpiAccounts<'a, T> { self.fee_payer } + pub fn light_system_program(&self) -> Result<&'a T> { + let index = CompressionCpiAccountIndex::LightSystemProgram as usize; + self.accounts + .get(index) + .ok_or(LightSdkTypesError::CpiAccountsIndexOutOfBounds(index)) + } + pub fn authority(&self) -> Result<&'a T> { let index = CompressionCpiAccountIndex::Authority as usize; self.accounts diff --git a/sdk-libs/sdk/Cargo.toml b/sdk-libs/sdk/Cargo.toml index dd95628d93..340e0cd6e8 100644 --- a/sdk-libs/sdk/Cargo.toml +++ b/sdk-libs/sdk/Cargo.toml @@ -27,6 +27,7 @@ keccak = ["light-hasher/keccak", "light-compressed-account/keccak"] sha256 = ["light-hasher/sha256", "light-compressed-account/sha256"] merkle-tree = ["light-concurrent-merkle-tree/solana"] anchor-discriminator = ["light-sdk-macros/anchor-discriminator"] +custom-heap = ["light-heap"] [dependencies] solana-pubkey = { workspace = true, features = ["borsh", "sha2", "curve25519"] } @@ -56,9 +57,17 @@ light-account-checks = { workspace = true, features = ["solana"] } light-zero-copy = { workspace = true } light-concurrent-merkle-tree = { workspace = true, optional = true } light-compressible = { workspace = true } +light-heap = { workspace = true, optional = true } [dev-dependencies] num-bigint = { workspace = true } light-compressed-account = { workspace = true, features = ["new-unique"] } light-hasher = { workspace = true, features = ["keccak"] } anchor-lang = { workspace = true } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/sdk-libs/sdk/src/compressible/compression_info.rs b/sdk-libs/sdk/src/compressible/compression_info.rs index 273eb0ed59..3ffb42d6c9 100644 --- a/sdk-libs/sdk/src/compressible/compression_info.rs +++ b/sdk-libs/sdk/src/compressible/compression_info.rs @@ -58,7 +58,7 @@ pub trait CompressAs { fn compress_as(&self) -> Cow<'_, Self::Output>; } -#[derive(Debug, Clone, Default, AnchorSerialize, AnchorDeserialize)] +#[derive(Debug, Clone, Default, PartialEq, AnchorSerialize, AnchorDeserialize)] pub struct CompressionInfo { /// Version of the compressible config used to initialize this account. pub config_version: u16, diff --git a/sdk-libs/sdk/src/compressible/decompress_runtime.rs b/sdk-libs/sdk/src/compressible/decompress_runtime.rs index d5e36d3387..c1302bcb0c 100644 --- a/sdk-libs/sdk/src/compressible/decompress_runtime.rs +++ b/sdk-libs/sdk/src/compressible/decompress_runtime.rs @@ -1,5 +1,8 @@ //! Traits and processor for decompress_accounts_idempotent instruction. -use light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo; +use light_compressed_account::instruction_data::{ + cpi_context::CompressedCpiContext, + with_account_info::{CompressedAccountInfo, InstructionDataInvokeCpiWithAccountInfo}, +}; use light_sdk_types::{ cpi_accounts::CpiAccountsConfig, cpi_context_write::CpiContextWriteAccounts, instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, CpiSigner, @@ -10,10 +13,7 @@ use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use crate::{ - cpi::{ - v2::{CpiAccounts, LightSystemProgramCpi}, - InvokeLightSystemProgram, LightCpiInstruction, - }, + cpi::{v2::CpiAccounts, InvokeLightSystemProgram}, AnchorDeserialize, AnchorSerialize, LightDiscriminator, }; @@ -175,7 +175,13 @@ where } let compressed_infos = { - let seed_refs: Vec<&[u8]> = seeds_vec.iter().map(|v| v.as_slice()).collect(); + // Use fixed-size array to avoid heap allocation (MAX_SEEDS = 16) + const MAX_SEEDS: usize = 16; + let mut seed_refs: [&[u8]; MAX_SEEDS] = [&[]; MAX_SEEDS]; + let len = seeds_vec.len().min(MAX_SEEDS); + for i in 0..len { + seed_refs[i] = seeds_vec[i].as_slice(); + } crate::compressible::decompress_idempotent::prepare_account_for_decompression_idempotent::( program_id, data, @@ -188,7 +194,7 @@ where solana_account, accounts_rent_sponsor, cpi_accounts, - seed_refs.as_slice(), + &seed_refs[..len], )? }; compressed_pda_infos.extend(compressed_infos); @@ -220,6 +226,7 @@ where 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(()); } @@ -274,29 +281,56 @@ where // Process PDAs (if any) if has_pdas { if !has_tokens { - // PDAs only - execute directly - LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) - .with_account_infos(&compressed_pda_infos) - .invoke(cpi_accounts.clone())?; + // PDAs only - execute directly (manual construction to avoid extra allocations) + let cpi_signer_config = cpi_accounts.config().cpi_signer; + let instruction_data = InstructionDataInvokeCpiWithAccountInfo { + mode: 1, + bump: cpi_signer_config.bump, + invoking_program_id: cpi_signer_config.program_id.into(), + compress_or_decompress_lamports: 0, + is_compress: false, + with_cpi_context: false, + with_transaction_hash: false, + cpi_context: CompressedCpiContext::default(), + proof: proof.0, + new_address_params: Vec::new(), + account_infos: compressed_pda_infos, + read_only_addresses: Vec::new(), + read_only_accounts: Vec::new(), + }; + instruction_data.invoke(cpi_accounts.clone())?; } else { // PDAs + tokens - write to CPI context first, tokens will execute let authority = cpi_accounts .authority() .map_err(|_| ProgramError::MissingRequiredSignature)?; - let cpi_context = cpi_accounts + let cpi_context_account = cpi_accounts .cpi_context() .map_err(|_| ProgramError::MissingRequiredSignature)?; let system_cpi_accounts = CpiContextWriteAccounts { fee_payer, authority, - cpi_context, + cpi_context: cpi_context_account, 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)?; + // Manual construction to avoid extra allocations + let instruction_data = InstructionDataInvokeCpiWithAccountInfo { + mode: 1, + bump: cpi_signer.bump, + invoking_program_id: cpi_signer.program_id.into(), + compress_or_decompress_lamports: 0, + is_compress: false, + with_cpi_context: true, + with_transaction_hash: false, + cpi_context: CompressedCpiContext::first(), + proof: proof.0, + new_address_params: Vec::new(), + account_infos: compressed_pda_infos, + read_only_addresses: Vec::new(), + read_only_accounts: Vec::new(), + }; + instruction_data.invoke_write_to_cpi_context_first(system_cpi_accounts)?; } } diff --git a/sdk-libs/sdk/src/cpi/invoke.rs b/sdk-libs/sdk/src/cpi/invoke.rs index 91b0bf479d..4562d0ad18 100644 --- a/sdk-libs/sdk/src/cpi/invoke.rs +++ b/sdk-libs/sdk/src/cpi/invoke.rs @@ -79,7 +79,6 @@ where accounts: account_metas, data, }; - invoke_light_system_program(&account_infos, instruction, self.get_bump()) } diff --git a/sdk-libs/sdk/src/cpi/v2/accounts.rs b/sdk-libs/sdk/src/cpi/v2/accounts.rs index d73215cc1b..bce47fe5ea 100644 --- a/sdk-libs/sdk/src/cpi/v2/accounts.rs +++ b/sdk-libs/sdk/src/cpi/v2/accounts.rs @@ -88,8 +88,8 @@ pub fn to_account_metas(cpi_accounts: &CpiAccounts<'_, '_>) -> Result1: 2N-1 CPIs (N-1 writes + 1 execute with decompress + N-1 decompress) + +use light_batched_merkle_tree::queue::BatchedQueueAccount; +use light_compressed_account::instruction_data::traits::LightInstructionData; +use light_token_interface::{ + instructions::mint_action::{ + Action, CpiContext, DecompressMintAction, MintActionCompressedInstructionData, + MintInstructionData, + }, + state::MintMetadata, + LIGHT_TOKEN_PROGRAM_ID, +}; +use solana_account_info::AccountInfo; +use solana_instruction::Instruction; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +use super::SystemAccountInfos; +use crate::compressed_token::mint_action::{ + get_mint_action_instruction_account_metas_cpi_write, MintActionMetaConfig, + MintActionMetaConfigCpiWrite, +}; + +/// Default rent payment epochs (~24 hours) +pub const DEFAULT_RENT_PAYMENT: u8 = 16; +/// Default lamports for write operations (~3 hours per write) +pub const DEFAULT_WRITE_TOP_UP: u32 = 766; + +/// Parameters for a single mint within a batch creation. +/// +/// Does not include proof since proof is shared across all mints in the batch. +#[derive(Debug, Clone)] +pub struct SingleMintParams<'a> { + pub decimals: u8, + pub address_merkle_tree_root_index: u16, + pub mint_authority: Pubkey, + pub compression_address: [u8; 32], + pub mint: Pubkey, + pub bump: u8, + pub freeze_authority: Option, + /// Mint seed pubkey (signer) for this mint + pub mint_seed_pubkey: Pubkey, + /// Optional authority seeds for PDA signing + pub authority_seeds: Option<&'a [&'a [u8]]>, + /// Optional mint signer seeds for PDA signing + pub mint_signer_seeds: Option<&'a [&'a [u8]]>, +} + +/// Parameters for creating one or more compressed mints with decompression. +/// +/// Creates N compressed mints and decompresses all to Solana Mint accounts. +/// Uses CPI context pattern when N > 1 for efficiency. +#[derive(Debug, Clone)] +pub struct CreateMintsParams<'a> { + /// Parameters for each mint to create + pub mints: &'a [SingleMintParams<'a>], + /// Single proof covering all new addresses + pub proof: light_compressed_account::instruction_data::compressed_proof::CompressedProof, + /// Rent payment in epochs for the Mint account (must be 0 or >= 2). + /// Default: 16 (~24 hours) + pub rent_payment: u8, + /// Lamports allocated for future write operations. + /// Default: 766 (~3 hours per write) + pub write_top_up: u32, + /// Offset for assigned_account_index when sharing CPI context with other accounts. + /// When creating mints alongside PDAs, this offset should be set to the number of + /// PDAs already written to the CPI context. + /// Default: 0 (no offset) + pub cpi_context_offset: u8, + /// Index of the output queue in tree accounts. + /// Default: 0 + pub output_queue_index: u8, + /// Index of the address merkle tree in tree accounts. + /// Default: 1 + pub address_tree_index: u8, + /// Index of the state merkle tree in tree accounts. + /// Required for decompress operations (discriminator validation). + /// Default: 2 + pub state_tree_index: u8, +} + +impl<'a> CreateMintsParams<'a> { + pub fn new( + mints: &'a [SingleMintParams<'a>], + proof: light_compressed_account::instruction_data::compressed_proof::CompressedProof, + ) -> Self { + Self { + mints, + proof, + rent_payment: DEFAULT_RENT_PAYMENT, + write_top_up: DEFAULT_WRITE_TOP_UP, + cpi_context_offset: 0, + output_queue_index: 0, + address_tree_index: 1, + state_tree_index: 2, + } + } + + pub fn with_rent_payment(mut self, rent_payment: u8) -> Self { + self.rent_payment = rent_payment; + self + } + + pub fn with_write_top_up(mut self, write_top_up: u32) -> Self { + self.write_top_up = write_top_up; + self + } + + /// Set offset for assigned_account_index when sharing CPI context. + /// + /// Use this when creating mints alongside PDAs. The offset should be + /// the number of accounts already written to the CPI context. + pub fn with_cpi_context_offset(mut self, offset: u8) -> Self { + self.cpi_context_offset = offset; + self + } + + /// Set the output queue index in tree accounts. + pub fn with_output_queue_index(mut self, index: u8) -> Self { + self.output_queue_index = index; + self + } + + /// Set the address merkle tree index in tree accounts. + pub fn with_address_tree_index(mut self, index: u8) -> Self { + self.address_tree_index = index; + self + } + + /// Set the state merkle tree index in tree accounts. + /// Required for decompress operations (discriminator validation). + pub fn with_state_tree_index(mut self, index: u8) -> Self { + self.state_tree_index = index; + self + } +} + +/// CPI struct for on-chain programs to create multiple mints. +/// +/// Uses named account fields for clarity and safety - no manual index calculations. +/// +/// # Example +/// +/// ```rust,ignore +/// use light_token_sdk::token::{CreateMintsCpi, CreateMintsParams, SingleMintParams, SystemAccountInfos}; +/// +/// let params = CreateMintsParams::new(vec![mint_params_1, mint_params_2], proof); +/// +/// CreateMintsCpi { +/// mint_seeds: vec![mint_signer1.clone(), mint_signer2.clone()], +/// payer: payer.clone(), +/// address_tree: address_tree.clone(), +/// output_queue: output_queue.clone(), +/// compressible_config: config.clone(), +/// mints: vec![mint_pda1.clone(), mint_pda2.clone()], +/// rent_sponsor: rent_sponsor.clone(), +/// system_accounts: SystemAccountInfos { ... }, +/// cpi_context_account: cpi_context.clone(), +/// params, +/// }.invoke()?; +/// ``` +pub struct CreateMintsCpi<'a, 'info> { + /// Mint seed accounts (signers) - one per mint + pub mint_seed_accounts: &'a [AccountInfo<'info>], + /// Fee payer (also used as authority) + pub payer: AccountInfo<'info>, + /// Address tree for new mint addresses + pub address_tree: AccountInfo<'info>, + /// Output queue for compressed accounts + pub output_queue: AccountInfo<'info>, + /// State merkle tree (required for decompress discriminator validation) + pub state_merkle_tree: AccountInfo<'info>, + /// CompressibleConfig account + pub compressible_config: AccountInfo<'info>, + /// Mint PDA accounts (writable) - one per mint + pub mints: &'a [AccountInfo<'info>], + /// Rent sponsor PDA + pub rent_sponsor: AccountInfo<'info>, + /// Standard Light Protocol system accounts + pub system_accounts: SystemAccountInfos<'info>, + /// CPI context account + pub cpi_context_account: AccountInfo<'info>, + /// Parameters + pub params: CreateMintsParams<'a>, +} + +impl<'a, 'info> CreateMintsCpi<'a, 'info> { + /// Validate that the struct is properly constructed. + pub fn validate(&self) -> Result<(), ProgramError> { + let n = self.params.mints.len(); + if n == 0 { + return Err(ProgramError::InvalidArgument); + } + if self.mint_seed_accounts.len() != n { + return Err(ProgramError::InvalidArgument); + } + if self.mints.len() != n { + return Err(ProgramError::InvalidArgument); + } + Ok(()) + } + + /// Execute all CPIs to create and decompress all mints. + /// + /// Signer seeds are extracted from `SingleMintParams::mint_signer_seeds` and + /// `SingleMintParams::authority_seeds` for each CPI call (0, 1, or 2 seeds per call). + pub fn invoke(self) -> Result<(), ProgramError> { + self.validate()?; + let n = self.params.mints.len(); + + // Use single mint path only when: + // - N=1 AND + // - No CPI context offset (no PDAs were written to CPI context first) + if n == 1 && self.params.cpi_context_offset == 0 { + self.invoke_single_mint() + } else { + self.invoke_multiple_mints() + } + } + + /// Handle the single mint case: create + decompress in one CPI. + #[inline(never)] + fn invoke_single_mint(self) -> Result<(), ProgramError> { + let mint_params = &self.params.mints[0]; + + let mint_data = build_mint_instruction_data(mint_params, self.mint_seed_accounts[0].key); + + let decompress_action = DecompressMintAction { + rent_payment: self.params.rent_payment, + write_top_up: self.params.write_top_up, + }; + + let instruction_data = MintActionCompressedInstructionData::new_mint( + mint_params.address_merkle_tree_root_index, + self.params.proof, + mint_data, + ) + .with_decompress_mint(decompress_action); + + let mut meta_config = MintActionMetaConfig::new_create_mint( + *self.payer.key, + *self.payer.key, + *self.mint_seed_accounts[0].key, + *self.address_tree.key, + *self.output_queue.key, + ) + .with_compressible_mint( + *self.mints[0].key, + *self.compressible_config.key, + *self.rent_sponsor.key, + ); + meta_config.input_queue = Some(*self.output_queue.key); + + self.invoke_mint_action(instruction_data, meta_config, 0) + } + + /// Handle the multiple mints case: N-1 writes + 1 execute + N-1 decompress. + #[inline(never)] + fn invoke_multiple_mints(self) -> Result<(), ProgramError> { + let n = self.params.mints.len(); + + // Get base leaf index before any CPIs modify the queue + let base_leaf_index = get_base_leaf_index(&self.output_queue)?; + + let decompress_action = DecompressMintAction { + rent_payment: self.params.rent_payment, + write_top_up: self.params.write_top_up, + }; + + // Write mints 0..N-2 to CPI context + for i in 0..(n - 1) { + self.invoke_cpi_write(i)?; + } + + // Execute: create last mint + decompress it + self.invoke_execute(n - 1, &decompress_action)?; + + // Decompress remaining mints (0..N-2) + for i in 0..(n - 1) { + self.invoke_decompress(i, base_leaf_index, &decompress_action)?; + } + + Ok(()) + } + + /// Invoke a CPI write instruction for a single mint. + /// Extracts signer seeds from mint params (0, 1, or 2 seeds). + #[inline(never)] + fn invoke_cpi_write(&self, index: usize) -> Result<(), ProgramError> { + let mint_params = &self.params.mints[index]; + let offset = self.params.cpi_context_offset; + + // When sharing CPI context with PDAs: + // - first_set_context: only true for index 0 AND offset 0 (first write to context) + // - set_context: true if appending to existing context (index > 0 or offset > 0) + // - assigned_account_index: offset + index (to not collide with PDA indices) + let cpi_context = CpiContext { + set_context: index > 0 || offset > 0, + first_set_context: index == 0 && offset == 0, + in_tree_index: self.params.address_tree_index, + in_queue_index: self.params.output_queue_index, + out_queue_index: self.params.output_queue_index, + token_out_queue_index: 0, + assigned_account_index: offset + index as u8, + read_only_address_trees: [0; 4], + address_tree_pubkey: self.address_tree.key.to_bytes(), + }; + + let mint_data = + build_mint_instruction_data(mint_params, self.mint_seed_accounts[index].key); + + let instruction_data = MintActionCompressedInstructionData::new_mint_write_to_cpi_context( + mint_params.address_merkle_tree_root_index, + mint_data, + cpi_context, + ); + + let cpi_write_config = MintActionMetaConfigCpiWrite { + fee_payer: *self.payer.key, + mint_signer: Some(*self.mint_seed_accounts[index].key), + authority: *self.payer.key, + cpi_context: *self.cpi_context_account.key, + }; + + let account_metas = get_mint_action_instruction_account_metas_cpi_write(cpi_write_config); + let ix_data = instruction_data + .data() + .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; + + // Account order matches get_mint_action_instruction_account_metas_cpi_write: + // [0]: light_system_program + // [1]: mint_signer (optional, when present) + // [2]: authority + // [3]: fee_payer + // [4]: cpi_authority_pda + // [5]: cpi_context + let account_infos = [ + self.system_accounts.light_system_program.clone(), + self.mint_seed_accounts[index].clone(), + self.payer.clone(), + self.payer.clone(), + self.system_accounts.cpi_authority_pda.clone(), + self.cpi_context_account.clone(), + ]; + let instruction = Instruction { + program_id: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + accounts: account_metas, + data: ix_data, + }; + + // Build signer seeds - pack present seeds at start of array + let mut seeds: [&[&[u8]]; 2] = [&[], &[]]; + let mut num_signers = 0; + if let Some(s) = mint_params.mint_signer_seeds { + seeds[num_signers] = s; + num_signers += 1; + } + if let Some(s) = mint_params.authority_seeds { + seeds[num_signers] = s; + num_signers += 1; + } + solana_cpi::invoke_signed(&instruction, &account_infos, &seeds[..num_signers]) + } + + /// Invoke the execute instruction (create last mint + decompress). + /// Extracts signer seeds from mint params (0, 1, or 2 seeds). + #[inline(never)] + fn invoke_execute( + &self, + last_idx: usize, + decompress_action: &DecompressMintAction, + ) -> Result<(), ProgramError> { + let mint_params = &self.params.mints[last_idx]; + let offset = self.params.cpi_context_offset; + + let execute_cpi_context = CpiContext { + set_context: false, + first_set_context: false, + in_tree_index: self.params.address_tree_index, + in_queue_index: self.params.address_tree_index, // CPI context queue index + out_queue_index: self.params.output_queue_index, + token_out_queue_index: 0, + assigned_account_index: offset + last_idx as u8, + read_only_address_trees: [0; 4], + address_tree_pubkey: self.address_tree.key.to_bytes(), + }; + + let mint_data = + build_mint_instruction_data(mint_params, self.mint_seed_accounts[last_idx].key); + + let instruction_data = MintActionCompressedInstructionData::new_mint( + mint_params.address_merkle_tree_root_index, + self.params.proof, + mint_data, + ) + .with_cpi_context(execute_cpi_context) + .with_decompress_mint(*decompress_action); + + let mut meta_config = MintActionMetaConfig::new_create_mint( + *self.payer.key, + *self.payer.key, + *self.mint_seed_accounts[last_idx].key, + *self.address_tree.key, + *self.output_queue.key, + ) + .with_compressible_mint( + *self.mints[last_idx].key, + *self.compressible_config.key, + *self.rent_sponsor.key, + ); + meta_config.cpi_context = Some(*self.cpi_context_account.key); + meta_config.input_queue = Some(*self.output_queue.key); + + self.invoke_mint_action(instruction_data, meta_config, last_idx) + } + + /// Invoke decompress for a single mint. + /// Extracts signer seeds from mint params (0, 1, or 2 seeds). + #[inline(never)] + fn invoke_decompress( + &self, + index: usize, + base_leaf_index: u32, + decompress_action: &DecompressMintAction, + ) -> Result<(), ProgramError> { + let mint_params = &self.params.mints[index]; + + let mint_data = + build_mint_instruction_data(mint_params, self.mint_seed_accounts[index].key); + + let instruction_data = MintActionCompressedInstructionData { + leaf_index: base_leaf_index + index as u32, + prove_by_index: true, + root_index: 0, + max_top_up: 0, + create_mint: None, + actions: vec![Action::DecompressMint(*decompress_action)], + proof: None, + cpi_context: None, + mint: Some(mint_data), + }; + + // For prove_by_index, the tree_pubkey must be state_merkle_tree for discriminator validation + let meta_config = MintActionMetaConfig::new( + *self.payer.key, + *self.payer.key, + *self.state_merkle_tree.key, // tree_pubkey - state merkle tree for discriminator check + *self.output_queue.key, // input_queue + *self.output_queue.key, // output_queue + ) + .with_compressible_mint( + *self.mints[index].key, + *self.compressible_config.key, + *self.rent_sponsor.key, + ); + + self.invoke_mint_action(instruction_data, meta_config, index) + } + + /// Invoke a mint action instruction. + /// Extracts signer seeds from mint params at the given index (0, 1, or 2 seeds). + #[inline(never)] + fn invoke_mint_action( + &self, + instruction_data: MintActionCompressedInstructionData, + meta_config: MintActionMetaConfig, + mint_index: usize, + ) -> Result<(), ProgramError> { + let account_metas = meta_config.to_account_metas(); + let ix_data = instruction_data + .data() + .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; + + // Collect all account infos needed for the CPI + let mut account_infos = vec![self.payer.clone()]; + + // System accounts + account_infos.push(self.system_accounts.light_system_program.clone()); + + // Add all mint seeds + for mint_seed in self.mint_seed_accounts { + account_infos.push(mint_seed.clone()); + } + + // More system accounts + account_infos.push(self.system_accounts.cpi_authority_pda.clone()); + account_infos.push(self.system_accounts.registered_program_pda.clone()); + account_infos.push(self.system_accounts.account_compression_authority.clone()); + account_infos.push(self.system_accounts.account_compression_program.clone()); + account_infos.push(self.system_accounts.system_program.clone()); + + // CPI context, queues, trees + account_infos.push(self.cpi_context_account.clone()); + account_infos.push(self.output_queue.clone()); + account_infos.push(self.state_merkle_tree.clone()); + account_infos.push(self.address_tree.clone()); + account_infos.push(self.compressible_config.clone()); + account_infos.push(self.rent_sponsor.clone()); + + // Add all mint PDAs + for mint in self.mints { + account_infos.push(mint.clone()); + } + + let instruction = Instruction { + program_id: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + accounts: account_metas, + data: ix_data, + }; + + // Build signer seeds - pack present seeds at start of array + let mint_params = &self.params.mints[mint_index]; + let mut seeds: [&[&[u8]]; 2] = [&[], &[]]; + let mut num_signers = 0; + if let Some(s) = mint_params.mint_signer_seeds { + seeds[num_signers] = s; + num_signers += 1; + } + if let Some(s) = mint_params.authority_seeds { + seeds[num_signers] = s; + num_signers += 1; + } + solana_cpi::invoke_signed(&instruction, &account_infos, &seeds[..num_signers]) + } +} + +/// Build MintInstructionData for a single mint. +#[inline(never)] +fn build_mint_instruction_data( + mint_params: &SingleMintParams<'_>, + mint_signer: &Pubkey, +) -> MintInstructionData { + MintInstructionData { + supply: 0, + decimals: mint_params.decimals, + metadata: MintMetadata { + version: 3, + mint: mint_params.mint.to_bytes().into(), + mint_decompressed: false, + mint_signer: mint_signer.to_bytes(), + bump: mint_params.bump, + }, + mint_authority: Some(mint_params.mint_authority.to_bytes().into()), + freeze_authority: mint_params.freeze_authority.map(|a| a.to_bytes().into()), + extensions: None, + } +} + +/// Get base leaf index from output queue account. +#[inline(never)] +fn get_base_leaf_index(output_queue: &AccountInfo) -> Result { + let queue = BatchedQueueAccount::output_from_account_info(output_queue) + .map_err(|_| ProgramError::InvalidAccountData)?; + Ok(queue.batch_metadata.next_index as u32) +} + +/// Create multiple mints and decompress all to Solana accounts. +/// +/// Convenience function that builds a [`CreateMintsCpi`] from a slice of accounts. +/// +/// # Arguments +/// +/// * `payer` - The fee payer account +/// * `accounts` - The remaining accounts in the expected layout +/// * `params` - Parameters for creating the mints +/// +/// # Account Layout +/// +/// - `[0]`: light_system_program +/// - `[1..N+1]`: mint_signers (SIGNER) +/// - `[N+1..N+6]`: system PDAs (cpi_authority, registered_program, compression_authority, compression_program, system_program) +/// - `[N+6]`: cpi_context_account (writable) +/// - `[N+7]`: output_queue (writable) +/// - `[N+8]`: state_merkle_tree (writable) +/// - `[N+9]`: address_tree (writable) +/// - `[N+10]`: compressible_config +/// - `[N+11]`: rent_sponsor (writable) +/// - `[N+12..2N+12]`: mint_pdas (writable) +/// - `[2N+12]`: compressed_token_program (for CPI) +pub fn create_mints<'a, 'info>( + payer: &AccountInfo<'info>, + accounts: &'info [AccountInfo<'info>], + params: CreateMintsParams<'a>, +) -> Result<(), ProgramError> { + if params.mints.is_empty() { + return Err(ProgramError::InvalidArgument); + } + + let n = params.mints.len(); + let mint_signers_start = 1; + let cpi_authority_idx = n + 1; + let registered_program_idx = n + 2; + let compression_authority_idx = n + 3; + let compression_program_idx = n + 4; + let system_program_idx = n + 5; + let cpi_context_idx = n + 6; + let output_queue_idx = n + 7; + let state_merkle_tree_idx = n + 8; + let address_tree_idx = n + 9; + let compressible_config_idx = n + 10; + let rent_sponsor_idx = n + 11; + let mint_pdas_start = n + 12; + + // Build named struct from accounts slice + CreateMintsCpi { + mint_seed_accounts: &accounts[mint_signers_start..mint_signers_start + n], + payer: payer.clone(), + address_tree: accounts[address_tree_idx].clone(), + output_queue: accounts[output_queue_idx].clone(), + state_merkle_tree: accounts[state_merkle_tree_idx].clone(), + compressible_config: accounts[compressible_config_idx].clone(), + mints: &accounts[mint_pdas_start..mint_pdas_start + n], + rent_sponsor: accounts[rent_sponsor_idx].clone(), + system_accounts: SystemAccountInfos { + light_system_program: accounts[0].clone(), + cpi_authority_pda: accounts[cpi_authority_idx].clone(), + registered_program_pda: accounts[registered_program_idx].clone(), + account_compression_authority: accounts[compression_authority_idx].clone(), + account_compression_program: accounts[compression_program_idx].clone(), + system_program: accounts[system_program_idx].clone(), + }, + cpi_context_account: accounts[cpi_context_idx].clone(), + params, + } + .invoke() +} + +// // ============================================================================ +// // Client-side instruction builder +// // ============================================================================ + +// /// Client-side instruction builder for creating multiple mints. +// /// +// /// This struct is used to build instructions for client-side transaction construction. +// /// For CPI usage within Solana programs, use [`CreateMintsCpi`] instead. +// /// +// /// # Example +// /// +// /// ```rust,ignore +// /// use light_token_sdk::token::{CreateMints, CreateMintsParams, SingleMintParams}; +// /// +// /// let params = CreateMintsParams::new(vec![mint1_params, mint2_params], proof); +// /// +// /// let instructions = CreateMints::new( +// /// params, +// /// mint_seed_pubkeys, +// /// payer, +// /// address_tree_pubkey, +// /// output_queue, +// /// state_merkle_tree, +// /// cpi_context_pubkey, +// /// ).instructions()?; +// /// ``` +// #[derive(Debug, Clone)] +// pub struct CreateMints<'a> { +// pub payer: Pubkey, +// pub address_tree_pubkey: Pubkey, +// pub output_queue: Pubkey, +// pub state_merkle_tree: Pubkey, +// pub cpi_context_pubkey: Pubkey, +// pub params: CreateMintsParams<'a>, +// } + +// impl<'a> CreateMints<'a> { +// #[allow(clippy::too_many_arguments)] +// pub fn new( +// params: CreateMintsParams<'a>, +// payer: Pubkey, +// address_tree_pubkey: Pubkey, +// output_queue: Pubkey, +// state_merkle_tree: Pubkey, +// cpi_context_pubkey: Pubkey, +// ) -> Self { +// Self { +// payer, +// address_tree_pubkey, +// output_queue, +// state_merkle_tree, +// cpi_context_pubkey, +// params, +// } +// } + +// /// Build account metas for the instruction. +// pub fn build_account_metas(&self) -> Vec { +// let system_accounts = SystemAccounts::default(); + +// let mut accounts = vec![AccountMeta::new_readonly( +// system_accounts.light_system_program, +// false, +// )]; + +// // Add mint signers (from each SingleMintParams) +// for mint_params in self.params.mints { +// accounts.push(AccountMeta::new_readonly( +// mint_params.mint_seed_pubkey, +// true, +// )); +// } + +// // Add system PDAs +// accounts.extend(vec![ +// AccountMeta::new_readonly(system_accounts.cpi_authority_pda, false), +// AccountMeta::new_readonly(system_accounts.registered_program_pda, false), +// AccountMeta::new_readonly(system_accounts.account_compression_authority, false), +// AccountMeta::new_readonly(system_accounts.account_compression_program, false), +// AccountMeta::new_readonly(system_accounts.system_program, false), +// ]); + +// // CPI context, output queue, address tree +// accounts.push(AccountMeta::new(self.cpi_context_pubkey, false)); +// accounts.push(AccountMeta::new(self.output_queue, false)); +// accounts.push(AccountMeta::new(self.address_tree_pubkey, false)); + +// // Config, rent sponsor +// accounts.push(AccountMeta::new_readonly(config_pda(), false)); +// accounts.push(AccountMeta::new(rent_sponsor_pda(), false)); + +// // State merkle tree +// accounts.push(AccountMeta::new(self.state_merkle_tree, false)); + +// // Add mint PDAs +// for mint_params in self.params.mints { +// accounts.push(AccountMeta::new(mint_params.mint, false)); +// } + +// accounts +// } +// } diff --git a/sdk-libs/token-sdk/src/token/mod.rs b/sdk-libs/token-sdk/src/token/mod.rs index 999d68cc85..b646594031 100644 --- a/sdk-libs/token-sdk/src/token/mod.rs +++ b/sdk-libs/token-sdk/src/token/mod.rs @@ -25,6 +25,7 @@ //! ## Mint //! //! - [`CreateMint`] - Create cMint +//! - [`CreateMints`] - Create multiple cMints in a batch //! - [`MintTo`] - Mint tokens to ctoken accounts //! //! ## Revoke and Thaw @@ -100,6 +101,7 @@ mod compressible; mod create; mod create_ata; mod create_mint; +mod create_mints; mod decompress; mod decompress_mint; mod freeze; @@ -125,6 +127,7 @@ pub use create_ata::{ CreateTokenAtaCpi as CreateAssociatedAccountCpi, CreateTokenAtaCpi, }; pub use create_mint::*; +pub use create_mints::*; pub use decompress::Decompress; pub use decompress_mint::*; pub use freeze::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml index 4941c8cfb6..dfaf92b4de 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml +++ b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml @@ -13,16 +13,19 @@ no-entrypoint = [] no-idl = [] no-log-ix-name = [] cpi = ["no-entrypoint"] +custom-heap = ["light-heap", "light-sdk/custom-heap"] default = [] idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build"] test-sbf = [] [dependencies] +light-heap = { workspace = true, optional = true } 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-msg = { workspace = true } solana-account-info = { workspace = true } solana-pubkey = { workspace = true } light-macros = { workspace = true, features = ["solana"] } @@ -30,7 +33,7 @@ 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 = "4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3", features = ["memo", "metadata", "idl-build"] } +anchor-spl = { version = "=0.31.1", git = "https://github.com/lightprotocol/anchor", rev = "da005d7f", features = ["memo", "metadata", "idl-build"] } light-token-interface = { workspace = true, features = ["anchor"] } light-token-sdk = { workspace = true, features = ["anchor", "compressible"] } light-token-types = { workspace = true, features = ["anchor"] } diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs index d9712eff94..2af4b45b79 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs @@ -74,7 +74,7 @@ pub struct InitializePool<'info> { seeds = [POOL_LP_MINT_SIGNER_SEED, pool_state.key().as_ref()], bump, )] - pub lp_mint_signer: UncheckedAccount<'info>, + pub lp_mint_signer: UncheckedAccount<'info>, // TODO: check where the cpi gets the seeds from #[account(mut)] #[light_mint( @@ -82,7 +82,7 @@ pub struct InitializePool<'info> { authority = authority, decimals = 9, mint_seeds = &[POOL_LP_MINT_SIGNER_SEED, self.pool_state.to_account_info().key.as_ref(), &[params.lp_mint_signer_bump]], - authority_seeds = &[AUTH_SEED.as_bytes(), &[params.authority_bump]] + authority_seeds = &[AUTH_SEED.as_bytes(), &[params.authority_bump]] // TODO: get the authority seeds from authority if defined )] pub lp_mint: UncheckedAccount<'info>, diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/d6_account_types.rs b/sdk-tests/csdk-anchor-full-derived-test/src/d6_account_types.rs new file mode 100644 index 0000000000..d9adf49fa8 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/d6_account_types.rs @@ -0,0 +1,3 @@ +//! Re-export d6_account_types from instructions module for top-level access. + +pub use crate::instructions::d6_account_types::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/d7_infra_names.rs b/sdk-tests/csdk-anchor-full-derived-test/src/d7_infra_names.rs new file mode 100644 index 0000000000..a3b207fd50 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/d7_infra_names.rs @@ -0,0 +1,3 @@ +//! Re-export d7_infra_names from instructions module for top-level access. + +pub use crate::instructions::d7_infra_names::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/d8_builder_paths.rs b/sdk-tests/csdk-anchor-full-derived-test/src/d8_builder_paths.rs new file mode 100644 index 0000000000..e6baf46321 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/d8_builder_paths.rs @@ -0,0 +1,3 @@ +//! Re-export d8_builder_paths from instructions module for top-level access. + +pub use crate::instructions::d8_builder_paths::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/d9_seeds.rs b/sdk-tests/csdk-anchor-full-derived-test/src/d9_seeds.rs new file mode 100644 index 0000000000..9606a49312 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/d9_seeds.rs @@ -0,0 +1,3 @@ +//! Re-export d9_seeds from instructions module for top-level access. + +pub use crate::instructions::d9_seeds::*; 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 index cc16540a9b..5c7185f4fb 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs @@ -116,3 +116,191 @@ pub struct CreatePdasAndMintAuto<'info> { } pub const VAULT_SEED: &[u8] = b"vault"; + +// ============================================================================= +// Two Mints Test +// ============================================================================= + +pub const MINT_SIGNER_A_SEED: &[u8] = b"mint_signer_a"; +pub const MINT_SIGNER_B_SEED: &[u8] = b"mint_signer_b"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreateTwoMintsParams { + pub create_accounts_proof: CreateAccountsProof, + pub mint_signer_a_bump: u8, + pub mint_signer_b_bump: u8, +} + +/// Test instruction with 2 #[light_mint] fields to verify multi-mint support. +#[derive(Accounts, RentFree)] +#[instruction(params: CreateTwoMintsParams)] +pub struct CreateTwoMints<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub authority: Signer<'info>, + + /// CHECK: PDA derived from authority for mint A + #[account( + seeds = [MINT_SIGNER_A_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer_a: UncheckedAccount<'info>, + + /// CHECK: PDA derived from authority for mint B + #[account( + seeds = [MINT_SIGNER_B_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer_b: UncheckedAccount<'info>, + + /// CHECK: Initialized by mint_action - first mint + #[account(mut)] + #[light_mint( + mint_signer = mint_signer_a, + authority = fee_payer, + decimals = 6, + mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_a_bump]] + )] + pub cmint_a: UncheckedAccount<'info>, + + /// CHECK: Initialized by mint_action - second mint + #[account(mut)] + #[light_mint( + mint_signer = mint_signer_b, + authority = fee_payer, + decimals = 9, + mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_b_bump]] + )] + pub cmint_b: UncheckedAccount<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + /// CHECK: CToken config + pub ctoken_compressible_config: AccountInfo<'info>, + + /// CHECK: CToken rent sponsor + #[account(mut)] + pub ctoken_rent_sponsor: AccountInfo<'info>, + + /// CHECK: CToken program + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority + pub ctoken_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================= +// Four Mints Test +// ============================================================================= + +pub const MINT_SIGNER_C_SEED: &[u8] = b"mint_signer_c"; +pub const MINT_SIGNER_D_SEED: &[u8] = b"mint_signer_d"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreateFourMintsParams { + pub create_accounts_proof: CreateAccountsProof, + pub mint_signer_a_bump: u8, + pub mint_signer_b_bump: u8, + pub mint_signer_c_bump: u8, + pub mint_signer_d_bump: u8, +} + +/// Test instruction with 4 #[light_mint] fields to verify multi-mint support. +#[derive(Accounts, RentFree)] +#[instruction(params: CreateFourMintsParams)] +pub struct CreateFourMints<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub authority: Signer<'info>, + + /// CHECK: PDA derived from authority for mint A + #[account( + seeds = [MINT_SIGNER_A_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer_a: UncheckedAccount<'info>, + + /// CHECK: PDA derived from authority for mint B + #[account( + seeds = [MINT_SIGNER_B_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer_b: UncheckedAccount<'info>, + + /// CHECK: PDA derived from authority for mint C + #[account( + seeds = [MINT_SIGNER_C_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer_c: UncheckedAccount<'info>, + + /// CHECK: PDA derived from authority for mint D + #[account( + seeds = [MINT_SIGNER_D_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer_d: UncheckedAccount<'info>, + + /// CHECK: Initialized by light_mint CPI + #[account(mut)] + #[light_mint( + mint_signer = mint_signer_a, + authority = fee_payer, + decimals = 6, + mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_a_bump]] + )] + pub cmint_a: UncheckedAccount<'info>, + + /// CHECK: Initialized by light_mint CPI + #[account(mut)] + #[light_mint( + mint_signer = mint_signer_b, + authority = fee_payer, + decimals = 8, + mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_b_bump]] + )] + pub cmint_b: UncheckedAccount<'info>, + + /// CHECK: Initialized by light_mint CPI + #[account(mut)] + #[light_mint( + mint_signer = mint_signer_c, + authority = fee_payer, + decimals = 9, + mint_seeds = &[MINT_SIGNER_C_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_c_bump]] + )] + pub cmint_c: UncheckedAccount<'info>, + + /// CHECK: Initialized by light_mint CPI + #[account(mut)] + #[light_mint( + mint_signer = mint_signer_d, + authority = fee_payer, + decimals = 12, + mint_seeds = &[MINT_SIGNER_D_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_d_bump]] + )] + pub cmint_d: UncheckedAccount<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + /// CHECK: CToken config + pub ctoken_compressible_config: AccountInfo<'info>, + + /// CHECK: CToken rent sponsor + #[account(mut)] + pub ctoken_rent_sponsor: AccountInfo<'info>, + + /// CHECK: CToken program + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority + pub ctoken_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/all.rs new file mode 100644 index 0000000000..274bd23148 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/all.rs @@ -0,0 +1,74 @@ +//! D5 Test: All marker types combined +//! +//! Tests #[rentfree] + #[rentfree_token] together in one instruction struct. +//! Note: #[light_mint] is tested separately in amm_test/initialize.rs. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; +use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +pub const D5_ALL_AUTH_SEED: &[u8] = b"d5_all_auth"; +pub const D5_ALL_VAULT_SEED: &[u8] = b"d5_all_vault"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D5AllMarkersParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests all marker types in one struct: +/// - #[rentfree] for PDA account +/// - #[rentfree_token] for token vault +#[derive(Accounts, RentFree)] +#[instruction(params: D5AllMarkersParams)] +pub struct D5AllMarkers<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Token mint + pub mint: AccountInfo<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + seeds = [D5_ALL_AUTH_SEED], + bump, + )] + pub d5_all_authority: UncheckedAccount<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d5_all_record", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d5_all_record: Account<'info, SinglePubkeyRecord>, + + #[account( + mut, + seeds = [D5_ALL_VAULT_SEED, mint.key().as_ref()], + bump, + )] + #[rentfree_token(authority = [D5_ALL_AUTH_SEED])] + pub d5_all_vault: UncheckedAccount<'info>, + + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub ctoken_compressible_config: AccountInfo<'info>, + + #[account(mut, address = CTOKEN_RENT_SPONSOR)] + pub ctoken_rent_sponsor: AccountInfo<'info>, + + /// CHECK: CToken program + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority + pub ctoken_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/mod.rs index ffb4619e50..3229d39f5e 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/mod.rs @@ -2,6 +2,11 @@ //! //! Tests #[rentfree], #[rentfree_token], and #[light_mint] attribute parsing. +mod all; mod rentfree_bare; -// Note rent free custom rightfully is a failing test case not added here. +mod rentfree_token; +// Note: rentfree_custom is a failing test case due to pre-existing AddressTreeInfo bug. + +pub use all::*; pub use rentfree_bare::*; +pub use rentfree_token::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/rentfree_token.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/rentfree_token.rs new file mode 100644 index 0000000000..34c35968f7 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/rentfree_token.rs @@ -0,0 +1,57 @@ +//! D5 Test: #[rentfree_token] attribute with authority seeds +//! +//! Tests that the #[rentfree_token(authority = [...])] attribute works correctly +//! for token accounts that need custom authority derivation. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; +use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + +pub const D5_VAULT_AUTH_SEED: &[u8] = b"d5_vault_auth"; +pub const D5_VAULT_SEED: &[u8] = b"d5_vault"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D5RentfreeTokenParams { + pub create_accounts_proof: CreateAccountsProof, + pub vault_bump: u8, +} + +/// Tests #[rentfree_token(authority = [...])] attribute compilation. +#[derive(Accounts, RentFree)] +#[instruction(params: D5RentfreeTokenParams)] +pub struct D5RentfreeToken<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Token mint + pub mint: AccountInfo<'info>, + + #[account( + seeds = [D5_VAULT_AUTH_SEED], + bump, + )] + pub vault_authority: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [D5_VAULT_SEED, mint.key().as_ref()], + bump, + )] + #[rentfree_token(authority = [D5_VAULT_AUTH_SEED])] + pub d5_token_vault: UncheckedAccount<'info>, + + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub ctoken_compressible_config: AccountInfo<'info>, + + #[account(mut, address = CTOKEN_RENT_SPONSOR)] + pub ctoken_rent_sponsor: AccountInfo<'info>, + + /// CHECK: CToken program + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority + pub ctoken_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/account.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/account.rs new file mode 100644 index 0000000000..ff5d00a3d0 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/account.rs @@ -0,0 +1,38 @@ +//! D6 Test: Direct Account<'info, T> type +//! +//! Tests that #[rentfree] works with Account<'info, T> directly (not boxed). + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D6AccountParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests #[rentfree] with direct Account<'info, T> type. +#[derive(Accounts, RentFree)] +#[instruction(params: D6AccountParams)] +pub struct D6Account<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d6_account", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d6_account_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/all.rs new file mode 100644 index 0000000000..ea2be23ff1 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/all.rs @@ -0,0 +1,53 @@ +//! D6 Test: Both Account<'info, T> and Box> together +//! +//! Tests that both account type variants work in the same struct. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::{ + d1_field_types::single_pubkey::SinglePubkeyRecord, + d2_compress_as::multiple::MultipleCompressAsRecord, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D6AllParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests both account types in one struct: +/// - Account<'info, T> (direct) +/// - Box> (boxed) +#[derive(Accounts, RentFree)] +#[instruction(params: D6AllParams)] +pub struct D6All<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d6_all_direct", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d6_all_direct: Account<'info, SinglePubkeyRecord>, + + #[account( + init, + payer = fee_payer, + space = 8 + MultipleCompressAsRecord::INIT_SPACE, + seeds = [b"d6_all_boxed", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d6_all_boxed: Box>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/boxed.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/boxed.rs new file mode 100644 index 0000000000..90e8f6ba2a --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/boxed.rs @@ -0,0 +1,39 @@ +//! D6 Test: Box> type +//! +//! Tests that #[rentfree] works with Box> (boxed account). +//! This exercises the Box unwrap path in seed_extraction.rs with is_boxed = true. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D6BoxedParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests #[rentfree] with Box> type. +#[derive(Accounts, RentFree)] +#[instruction(params: D6BoxedParams)] +pub struct D6Boxed<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d6_boxed", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d6_boxed_record: Box>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/mod.rs new file mode 100644 index 0000000000..3d0f13e0b2 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d6_account_types/mod.rs @@ -0,0 +1,13 @@ +//! D6: Account type extraction +//! +//! Tests macro handling of different account wrapper types: +//! - Account<'info, T> - direct extraction +//! - Box> - Box unwrap with is_boxed = true + +mod account; +mod all; +mod boxed; + +pub use account::*; +pub use all::*; +pub use boxed::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/all.rs new file mode 100644 index 0000000000..9c6038e80d --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/all.rs @@ -0,0 +1,74 @@ +//! D7 Test: Multiple naming variants combined +//! +//! Tests that different naming conventions work together in one struct. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; +use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +pub const D7_ALL_AUTH_SEED: &[u8] = b"d7_all_auth"; +pub const D7_ALL_VAULT_SEED: &[u8] = b"d7_all_vault"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D7AllNamesParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests multiple naming variants: +/// - `payer` as the fee payer field +/// - `ctoken_compressible_config` for config +/// - `ctoken_rent_sponsor` for rent sponsor +#[derive(Accounts, RentFree)] +#[instruction(params: D7AllNamesParams)] +pub struct D7AllNames<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: Token mint + pub mint: AccountInfo<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + seeds = [D7_ALL_AUTH_SEED], + bump, + )] + pub d7_all_authority: UncheckedAccount<'info>, + + #[account( + init, + payer = payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d7_all_record", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d7_all_record: Account<'info, SinglePubkeyRecord>, + + #[account( + mut, + seeds = [D7_ALL_VAULT_SEED, mint.key().as_ref()], + bump, + )] + #[rentfree_token(authority = [D7_ALL_AUTH_SEED])] + pub d7_all_vault: UncheckedAccount<'info>, + + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub ctoken_compressible_config: AccountInfo<'info>, + + #[account(mut, address = CTOKEN_RENT_SPONSOR)] + pub ctoken_rent_sponsor: AccountInfo<'info>, + + /// CHECK: CToken program + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority + pub ctoken_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/creator.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/creator.rs new file mode 100644 index 0000000000..3dbfb1492b --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/creator.rs @@ -0,0 +1,38 @@ +//! D7 Test: "creator" field name variant +//! +//! Tests that #[rentfree] works when the payer field is named `creator` instead of `fee_payer`. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D7CreatorParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests #[rentfree] with `creator` field name (InfraFieldClassifier FeePayer variant). +#[derive(Accounts, RentFree)] +#[instruction(params: D7CreatorParams)] +pub struct D7Creator<'info> { + #[account(mut)] + pub creator: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = creator, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d7_creator", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d7_creator_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/ctoken_config.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/ctoken_config.rs new file mode 100644 index 0000000000..b4c8a6dac1 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/ctoken_config.rs @@ -0,0 +1,55 @@ +//! D7 Test: "ctoken_config" naming variant +//! +//! Tests that #[rentfree_token] works with alternative naming for ctoken infrastructure fields. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; +use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + +pub const D7_CTOKEN_AUTH_SEED: &[u8] = b"d7_ctoken_auth"; +pub const D7_CTOKEN_VAULT_SEED: &[u8] = b"d7_ctoken_vault"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D7CtokenConfigParams { + pub create_accounts_proof: CreateAccountsProof, +} + +/// Tests #[rentfree_token] with `ctoken_compressible_config` and `ctoken_rent_sponsor` field names. +#[derive(Accounts, RentFree)] +#[instruction(params: D7CtokenConfigParams)] +pub struct D7CtokenConfig<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Token mint + pub mint: AccountInfo<'info>, + + #[account( + seeds = [D7_CTOKEN_AUTH_SEED], + bump, + )] + pub d7_ctoken_authority: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [D7_CTOKEN_VAULT_SEED, mint.key().as_ref()], + bump, + )] + #[rentfree_token(authority = [D7_CTOKEN_AUTH_SEED])] + pub d7_ctoken_vault: UncheckedAccount<'info>, + + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub ctoken_compressible_config: AccountInfo<'info>, + + #[account(mut, address = CTOKEN_RENT_SPONSOR)] + pub ctoken_rent_sponsor: AccountInfo<'info>, + + /// CHECK: CToken program + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority + pub ctoken_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/mod.rs new file mode 100644 index 0000000000..bc8e7d2259 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/mod.rs @@ -0,0 +1,16 @@ +//! D7: Infrastructure field naming +//! +//! Tests macro handling of different field naming conventions: +//! - payer instead of fee_payer +//! - creator instead of fee_payer +//! - ctoken_config variants + +mod all; +mod creator; +mod ctoken_config; +mod payer; + +pub use all::*; +pub use creator::*; +pub use ctoken_config::*; +pub use payer::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/payer.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/payer.rs new file mode 100644 index 0000000000..721fd489a7 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/payer.rs @@ -0,0 +1,38 @@ +//! D7 Test: "payer" field name variant +//! +//! Tests that #[rentfree] works when the payer field is named `payer` instead of `fee_payer`. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D7PayerParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests #[rentfree] with `payer` field name (InfraFieldClassifier FeePayer variant). +#[derive(Accounts, RentFree)] +#[instruction(params: D7PayerParams)] +pub struct D7Payer<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d7_payer", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d7_payer_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/all.rs new file mode 100644 index 0000000000..1bbdf87791 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/all.rs @@ -0,0 +1,51 @@ +//! D8 Test: Multiple #[rentfree] fields with different state types +//! +//! Tests the builder path with multiple #[rentfree] fields of different state types. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::{ + d1_field_types::single_pubkey::SinglePubkeyRecord, + d2_compress_as::multiple::MultipleCompressAsRecord, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D8AllParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests builder path with multiple #[rentfree] fields of different state types. +#[derive(Accounts, RentFree)] +#[instruction(params: D8AllParams)] +pub struct D8All<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d8_all_single", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d8_all_single: Account<'info, SinglePubkeyRecord>, + + #[account( + init, + payer = fee_payer, + space = 8 + MultipleCompressAsRecord::INIT_SPACE, + seeds = [b"d8_all_multi", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d8_all_multi: Box>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/mod.rs new file mode 100644 index 0000000000..b9639ba8c4 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/mod.rs @@ -0,0 +1,14 @@ +//! D8: Builder code generation paths +//! +//! Tests different builder code generation scenarios: +//! - pda_only: Only #[rentfree] fields (no tokens) +//! - multi_rentfree: Multiple #[rentfree] fields +//! - all: Multiple #[rentfree] fields with different state types + +mod all; +mod multi_rentfree; +mod pda_only; + +pub use all::*; +pub use multi_rentfree::*; +pub use pda_only::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/multi_rentfree.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/multi_rentfree.rs new file mode 100644 index 0000000000..bf4760215a --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/multi_rentfree.rs @@ -0,0 +1,50 @@ +//! D8 Test: Multiple #[rentfree] fields +//! +//! Tests the builder path with multiple #[rentfree] PDA accounts of the same type. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D8MultiRentfreeParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, + pub id1: u64, + pub id2: u64, +} + +/// Tests builder path with multiple #[rentfree] fields of the same type. +#[derive(Accounts, RentFree)] +#[instruction(params: D8MultiRentfreeParams)] +pub struct D8MultiRentfree<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d8_multi_1", params.owner.as_ref(), params.id1.to_le_bytes().as_ref()], + bump, + )] + #[rentfree] + pub d8_multi_record1: Account<'info, SinglePubkeyRecord>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d8_multi_2", params.owner.as_ref(), params.id2.to_le_bytes().as_ref()], + bump, + )] + #[rentfree] + pub d8_multi_record2: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/pda_only.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/pda_only.rs new file mode 100644 index 0000000000..1db85edcca --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d8_builder_paths/pda_only.rs @@ -0,0 +1,39 @@ +//! D8 Test: Only #[rentfree] fields (no token accounts) +//! +//! Tests the `generate_pre_init_pdas_only` code path where only PDA accounts +//! are marked with #[rentfree], without any token accounts. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D8PdaOnlyParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests builder path with only PDA accounts (no token accounts). +#[derive(Accounts, RentFree)] +#[instruction(params: D8PdaOnlyParams)] +pub struct D8PdaOnly<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d8_pda_only", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d8_pda_only_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/all.rs new file mode 100644 index 0000000000..f0294d6cda --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/all.rs @@ -0,0 +1,108 @@ +//! D9 Test: All seed expression types +//! +//! Tests all 6 seed types in a single struct: +//! - Literal: b"d9_all" +//! - Constant: D9_ALL_SEED +//! - CtxAccount: authority.key() +//! - DataField (param): params.owner.as_ref() +//! - DataField (bytes): params.id.to_le_bytes() +//! - FunctionCall: max_key(&a, &b) + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +pub const D9_ALL_SEED: &[u8] = b"d9_all_const"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9AllParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, + pub id: u64, + pub key_a: Pubkey, + pub key_b: Pubkey, +} + +/// Tests all 6 seed types in one struct. +#[derive(Accounts, RentFree)] +#[instruction(params: D9AllParams)] +pub struct D9All<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Authority account used in seeds + pub authority: AccountInfo<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + // Test 1: Literal only + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_all_lit"], + bump, + )] + #[rentfree] + pub d9_all_lit: Account<'info, SinglePubkeyRecord>, + + // Test 2: Constant + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [D9_ALL_SEED], + bump, + )] + #[rentfree] + pub d9_all_const: Account<'info, SinglePubkeyRecord>, + + // Test 3: CtxAccount + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_all_ctx", authority.key().as_ref()], + bump, + )] + #[rentfree] + pub d9_all_ctx: Account<'info, SinglePubkeyRecord>, + + // Test 4: DataField (param Pubkey) + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_all_param", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d9_all_param: Account<'info, SinglePubkeyRecord>, + + // Test 5: DataField (bytes conversion) + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_all_bytes", params.id.to_le_bytes().as_ref()], + bump, + )] + #[rentfree] + pub d9_all_bytes: Account<'info, SinglePubkeyRecord>, + + // Test 6: FunctionCall + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_all_func", crate::max_key(¶ms.key_a, ¶ms.key_b).as_ref()], + bump, + )] + #[rentfree] + pub d9_all_func: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/constant.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/constant.rs new file mode 100644 index 0000000000..6337844b3d --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/constant.rs @@ -0,0 +1,39 @@ +//! D9 Test: Constant seed expression +//! +//! Tests ClassifiedSeed::Constant with constant identifier seeds. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +pub const D9_CONSTANT_SEED: &[u8] = b"d9_constant"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9ConstantParams { + pub create_accounts_proof: CreateAccountsProof, +} + +/// Tests ClassifiedSeed::Constant with constant identifier seeds. +#[derive(Accounts, RentFree)] +#[instruction(params: D9ConstantParams)] +pub struct D9Constant<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [D9_CONSTANT_SEED], + bump, + )] + #[rentfree] + pub d9_constant_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/ctx_account.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/ctx_account.rs new file mode 100644 index 0000000000..d2dd23db87 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/ctx_account.rs @@ -0,0 +1,40 @@ +//! D9 Test: Context account seed expression +//! +//! Tests ClassifiedSeed::CtxAccount with authority.key() seeds. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9CtxAccountParams { + pub create_accounts_proof: CreateAccountsProof, +} + +/// Tests ClassifiedSeed::CtxAccount with authority.key() seeds. +#[derive(Accounts, RentFree)] +#[instruction(params: D9CtxAccountParams)] +pub struct D9CtxAccount<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Authority account used in seeds + pub authority: AccountInfo<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_ctx", authority.key().as_ref()], + bump, + )] + #[rentfree] + pub d9_ctx_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/function_call.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/function_call.rs new file mode 100644 index 0000000000..af90c7bf77 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/function_call.rs @@ -0,0 +1,39 @@ +//! D9 Test: Function call seed expression +//! +//! Tests ClassifiedSeed::FunctionCall with max_key(&a, &b) seeds. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9FunctionCallParams { + pub create_accounts_proof: CreateAccountsProof, + pub key_a: Pubkey, + pub key_b: Pubkey, +} + +/// Tests ClassifiedSeed::FunctionCall with max_key(&a, &b) seeds. +#[derive(Accounts, RentFree)] +#[instruction(params: D9FunctionCallParams)] +pub struct D9FunctionCall<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_func", crate::max_key(¶ms.key_a, ¶ms.key_b).as_ref()], + bump, + )] + #[rentfree] + pub d9_func_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/literal.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/literal.rs new file mode 100644 index 0000000000..26a58095b8 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/literal.rs @@ -0,0 +1,37 @@ +//! D9 Test: Literal seed expression +//! +//! Tests ClassifiedSeed::Literal with byte literal seeds. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9LiteralParams { + pub create_accounts_proof: CreateAccountsProof, +} + +/// Tests ClassifiedSeed::Literal with byte literal seeds. +#[derive(Accounts, RentFree)] +#[instruction(params: D9LiteralParams)] +pub struct D9Literal<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_literal_record"], + bump, + )] + #[rentfree] + pub d9_literal_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mixed.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mixed.rs new file mode 100644 index 0000000000..bbc64a0619 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mixed.rs @@ -0,0 +1,41 @@ +//! D9 Test: Mixed seed expression types +//! +//! Tests multiple seed types combined: literal + ctx_account + param. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9MixedParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests multiple seed types combined: literal + ctx_account + param. +#[derive(Accounts, RentFree)] +#[instruction(params: D9MixedParams)] +pub struct D9Mixed<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Authority account used in seeds + pub authority: AccountInfo<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_mixed", authority.key().as_ref(), params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d9_mixed_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mod.rs new file mode 100644 index 0000000000..e34cfa99bf --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mod.rs @@ -0,0 +1,27 @@ +//! D9: Seed expression classification +//! +//! Tests different seed expression types in ClassifiedSeed enum: +//! - Literal: b"record" +//! - Constant: SEED_CONSTANT +//! - CtxAccount: authority.key() +//! - DataField (param): params.owner.as_ref() +//! - DataField (bytes): params.id.to_le_bytes() +//! - FunctionCall: max_key(&a, &b) + +mod all; +mod constant; +mod ctx_account; +mod function_call; +mod literal; +mod mixed; +mod param; +mod param_bytes; + +pub use all::*; +pub use constant::*; +pub use ctx_account::*; +pub use function_call::*; +pub use literal::*; +pub use mixed::*; +pub use param::*; +pub use param_bytes::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/param.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/param.rs new file mode 100644 index 0000000000..e90715a32a --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/param.rs @@ -0,0 +1,38 @@ +//! D9 Test: Param seed expression (Pubkey) +//! +//! Tests ClassifiedSeed::DataField with params.owner.as_ref() seeds. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9ParamParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests ClassifiedSeed::DataField with params.owner.as_ref() seeds. +#[derive(Accounts, RentFree)] +#[instruction(params: D9ParamParams)] +pub struct D9Param<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_param", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub d9_param_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/param_bytes.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/param_bytes.rs new file mode 100644 index 0000000000..db6996b644 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/param_bytes.rs @@ -0,0 +1,38 @@ +//! D9 Test: Param bytes seed expression +//! +//! Tests ClassifiedSeed::DataField with params.id.to_le_bytes() conversion. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; + +use crate::state::d1_field_types::single_pubkey::SinglePubkeyRecord; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9ParamBytesParams { + pub create_accounts_proof: CreateAccountsProof, + pub id: u64, +} + +/// Tests ClassifiedSeed::DataField with params.id.to_le_bytes() conversion. +#[derive(Accounts, RentFree)] +#[instruction(params: D9ParamBytesParams)] +pub struct D9ParamBytes<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"d9_param_bytes", params.id.to_le_bytes().as_ref()], + bump, + )] + #[rentfree] + pub d9_param_bytes_record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs index e5f94831ee..a7e9e9d180 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs @@ -8,3 +8,7 @@ //! - d9_seeds: Seed expression classification pub mod d5_markers; +pub mod d6_account_types; +pub mod d7_infra_names; +pub mod d8_builder_paths; +pub mod d9_seeds; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index 676abac7cb..3da00b3738 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -7,6 +7,10 @@ use light_sdk_types::CpiSigner; pub mod amm_test; pub mod d5_markers; +pub mod d6_account_types; +pub mod d7_infra_names; +pub mod d8_builder_paths; +pub mod d9_seeds; pub mod errors; pub mod instruction_accounts; pub mod instructions; @@ -14,10 +18,43 @@ pub mod processors; pub mod state; pub use amm_test::*; pub use d5_markers::*; +pub use d6_account_types::*; +pub use d7_infra_names::*; +pub use d8_builder_paths::*; +pub use d9_seeds::*; pub use instruction_accounts::*; +pub use instructions::{ + d7_infra_names::{ + D7_ALL_AUTH_SEED, D7_ALL_VAULT_SEED, D7_CTOKEN_AUTH_SEED, D7_CTOKEN_VAULT_SEED, + }, + d9_seeds::{D9_ALL_SEED, D9_CONSTANT_SEED}, +}; pub use state::{ - d1_field_types::single_pubkey::{PackedSinglePubkeyRecord, SinglePubkeyRecord}, - GameSession, PackedGameSession, PackedUserRecord, PlaceholderRecord, UserRecord, + d1_field_types::{ + all::{AllFieldTypesRecord, PackedAllFieldTypesRecord}, + arrays::ArrayRecord, + multi_pubkey::{MultiPubkeyRecord, PackedMultiPubkeyRecord}, + no_pubkey::NoPubkeyRecord, + non_copy::NonCopyRecord, + option_primitive::OptionPrimitiveRecord, + option_pubkey::{OptionPubkeyRecord, PackedOptionPubkeyRecord}, + single_pubkey::{PackedSinglePubkeyRecord, SinglePubkeyRecord}, + }, + d2_compress_as::{ + absent::{NoCompressAsRecord, PackedNoCompressAsRecord}, + all::{AllCompressAsRecord, PackedAllCompressAsRecord}, + multiple::{MultipleCompressAsRecord, PackedMultipleCompressAsRecord}, + option_none::{OptionNoneCompressAsRecord, PackedOptionNoneCompressAsRecord}, + single::{PackedSingleCompressAsRecord, SingleCompressAsRecord}, + }, + d4_composition::{ + all::{AllCompositionRecord, PackedAllCompositionRecord}, + info_last::{InfoLastRecord, PackedInfoLastRecord}, + large::LargeRecord, + minimal::MinimalRecord, + }, + GameSession, PackedGameSession, PackedPlaceholderRecord, PackedUserRecord, PlaceholderRecord, + UserRecord, }; #[inline] pub fn max_key(left: &Pubkey, right: &Pubkey) -> [u8; 32] { @@ -50,8 +87,27 @@ pub mod csdk_anchor_full_derived_test { use super::{ amm_test::{Deposit, InitializeParams, InitializePool, Withdraw}, - d5_markers::{D5RentfreeBare, D5RentfreeBareParams}, - instruction_accounts::CreatePdasAndMintAuto, + d5_markers::{ + D5AllMarkers, D5AllMarkersParams, D5RentfreeBare, D5RentfreeBareParams, + D5RentfreeToken, D5RentfreeTokenParams, + }, + d6_account_types::{D6Account, D6AccountParams, D6Boxed, D6BoxedParams}, + d7_infra_names::{ + D7AllNames, D7AllNamesParams, D7Creator, D7CreatorParams, D7CtokenConfig, + D7CtokenConfigParams, D7Payer, D7PayerParams, + }, + d8_builder_paths::{ + D8All, D8AllParams, D8MultiRentfree, D8MultiRentfreeParams, D8PdaOnly, D8PdaOnlyParams, + }, + d9_seeds::{ + D9All, D9AllParams, D9Constant, D9ConstantParams, D9CtxAccount, D9CtxAccountParams, + D9FunctionCall, D9FunctionCallParams, D9Literal, D9LiteralParams, D9Mixed, + D9MixedParams, D9Param, D9ParamBytes, D9ParamBytesParams, D9ParamParams, + }, + instruction_accounts::{ + CreateFourMints, CreateFourMintsParams, CreatePdasAndMintAuto, CreateTwoMints, + CreateTwoMintsParams, + }, FullAutoWithMintParams, LIGHT_CPI_SIGNER, }; @@ -59,7 +115,6 @@ pub mod csdk_anchor_full_derived_test { ctx: Context<'_, '_, '_, 'info, CreatePdasAndMintAuto<'info>>, params: FullAutoWithMintParams, ) -> Result<()> { - use anchor_lang::solana_program::sysvar::clock::Clock; use light_token_sdk::token::{ CreateTokenAccountCpi, CreateTokenAtaCpi, MintToCpi as CTokenMintToCpi, }; @@ -74,7 +129,7 @@ pub mod csdk_anchor_full_derived_test { game_session.session_id = params.session_id; game_session.player = ctx.accounts.fee_payer.key(); game_session.game_type = "Auto Game With Mint".to_string(); - game_session.start_time = Clock::get()?.unix_timestamp as u64; + game_session.start_time = 2; // Hardcoded non-zero for compress_as test game_session.end_time = None; game_session.score = 0; @@ -148,6 +203,30 @@ pub mod csdk_anchor_full_derived_test { crate::processors::process_create_single_record(ctx, params) } + /// Test instruction that creates 2 mints in a single transaction. + /// Tests the multi-mint support in the RentFree macro. + #[allow(unused_variables)] + pub fn create_two_mints<'info>( + ctx: Context<'_, '_, '_, 'info, CreateTwoMints<'info>>, + params: CreateTwoMintsParams, + ) -> Result<()> { + // Both mints are created by the RentFree macro in pre_init + // Nothing to do here - just verify both mints exist + Ok(()) + } + + /// Test instruction that creates 4 mints in a single transaction. + /// Tests the multi-mint support in the RentFree macro scales beyond 2. + #[allow(unused_variables)] + pub fn create_four_mints<'info>( + ctx: Context<'_, '_, '_, 'info, CreateFourMints<'info>>, + params: CreateFourMintsParams, + ) -> Result<()> { + // All 4 mints are created by the RentFree macro in pre_init + // Nothing to do here - just verify all mints exist + Ok(()) + } + /// AMM initialize instruction with all rentfree markers. /// Tests: 2x #[rentfree], 2x #[rentfree_token], 1x #[light_mint], /// CreateTokenAccountCpi.rent_free(), CreateTokenAtaCpi.rent_free(), MintToCpi @@ -167,4 +246,311 @@ pub mod csdk_anchor_full_derived_test { pub fn withdraw(ctx: Context, lp_token_amount: u64) -> Result<()> { crate::amm_test::process_withdraw(ctx, lp_token_amount) } + + // ========================================================================= + // D6 Account Types: Account type extraction + // ========================================================================= + + /// D6: Direct Account<'info, T> type + pub fn d6_account<'info>( + ctx: Context<'_, '_, '_, 'info, D6Account<'info>>, + params: D6AccountParams, + ) -> Result<()> { + ctx.accounts.d6_account_record.owner = params.owner; + Ok(()) + } + + /// D6: Box> type + pub fn d6_boxed<'info>( + ctx: Context<'_, '_, '_, 'info, D6Boxed<'info>>, + params: D6BoxedParams, + ) -> Result<()> { + ctx.accounts.d6_boxed_record.owner = params.owner; + Ok(()) + } + + // ========================================================================= + // D8 Builder Paths: Builder code generation paths + // ========================================================================= + + /// D8: Only #[rentfree] fields (no token accounts) + pub fn d8_pda_only<'info>( + ctx: Context<'_, '_, '_, 'info, D8PdaOnly<'info>>, + params: D8PdaOnlyParams, + ) -> Result<()> { + ctx.accounts.d8_pda_only_record.owner = params.owner; + Ok(()) + } + + /// D8: Multiple #[rentfree] fields of same type + pub fn d8_multi_rentfree<'info>( + ctx: Context<'_, '_, '_, 'info, D8MultiRentfree<'info>>, + params: D8MultiRentfreeParams, + ) -> Result<()> { + ctx.accounts.d8_multi_record1.owner = params.owner; + ctx.accounts.d8_multi_record2.owner = params.owner; + Ok(()) + } + + /// D8: Multiple #[rentfree] fields of different types + pub fn d8_all<'info>( + ctx: Context<'_, '_, '_, 'info, D8All<'info>>, + params: D8AllParams, + ) -> Result<()> { + ctx.accounts.d8_all_single.owner = params.owner; + ctx.accounts.d8_all_multi.owner = params.owner; + Ok(()) + } + + // ========================================================================= + // D9 Seeds: Seed expression classification + // ========================================================================= + + /// D9: Literal seed expression + pub fn d9_literal<'info>( + ctx: Context<'_, '_, '_, 'info, D9Literal<'info>>, + _params: D9LiteralParams, + ) -> Result<()> { + ctx.accounts.d9_literal_record.owner = ctx.accounts.fee_payer.key(); + Ok(()) + } + + /// D9: Constant seed expression + pub fn d9_constant<'info>( + ctx: Context<'_, '_, '_, 'info, D9Constant<'info>>, + _params: D9ConstantParams, + ) -> Result<()> { + ctx.accounts.d9_constant_record.owner = ctx.accounts.fee_payer.key(); + Ok(()) + } + + /// D9: Context account seed expression + pub fn d9_ctx_account<'info>( + ctx: Context<'_, '_, '_, 'info, D9CtxAccount<'info>>, + _params: D9CtxAccountParams, + ) -> Result<()> { + ctx.accounts.d9_ctx_record.owner = ctx.accounts.authority.key(); + Ok(()) + } + + /// D9: Param seed expression (Pubkey) + pub fn d9_param<'info>( + ctx: Context<'_, '_, '_, 'info, D9Param<'info>>, + params: D9ParamParams, + ) -> Result<()> { + ctx.accounts.d9_param_record.owner = params.owner; + Ok(()) + } + + /// D9: Param bytes seed expression (u64) + pub fn d9_param_bytes<'info>( + ctx: Context<'_, '_, '_, 'info, D9ParamBytes<'info>>, + _params: D9ParamBytesParams, + ) -> Result<()> { + ctx.accounts.d9_param_bytes_record.owner = ctx.accounts.fee_payer.key(); + Ok(()) + } + + /// D9: Mixed seed expression types + pub fn d9_mixed<'info>( + ctx: Context<'_, '_, '_, 'info, D9Mixed<'info>>, + params: D9MixedParams, + ) -> Result<()> { + ctx.accounts.d9_mixed_record.owner = params.owner; + Ok(()) + } + + // ========================================================================= + // D7 Infrastructure Names: Field naming convention tests + // ========================================================================= + + /// D7: "payer" field name variant (instead of fee_payer) + pub fn d7_payer<'info>( + ctx: Context<'_, '_, '_, 'info, D7Payer<'info>>, + params: D7PayerParams, + ) -> Result<()> { + ctx.accounts.d7_payer_record.owner = params.owner; + Ok(()) + } + + /// D7: "creator" field name variant (instead of fee_payer) + pub fn d7_creator<'info>( + ctx: Context<'_, '_, '_, 'info, D7Creator<'info>>, + params: D7CreatorParams, + ) -> Result<()> { + ctx.accounts.d7_creator_record.owner = params.owner; + Ok(()) + } + + /// D7: "ctoken_config" naming variant for token accounts + pub fn d7_ctoken_config<'info>( + ctx: Context<'_, '_, '_, 'info, D7CtokenConfig<'info>>, + _params: D7CtokenConfigParams, + ) -> Result<()> { + use light_token_sdk::token::CreateTokenAccountCpi; + + let mint_key = ctx.accounts.mint.key(); + // Derive the vault bump at runtime + let (_, vault_bump) = Pubkey::find_program_address( + &[ + crate::d7_infra_names::D7_CTOKEN_VAULT_SEED, + mint_key.as_ref(), + ], + &crate::ID, + ); + + CreateTokenAccountCpi { + payer: ctx.accounts.fee_payer.to_account_info(), + account: ctx.accounts.d7_ctoken_vault.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + owner: ctx.accounts.d7_ctoken_authority.key(), + } + .rent_free( + ctx.accounts.ctoken_compressible_config.to_account_info(), + ctx.accounts.ctoken_rent_sponsor.to_account_info(), + ctx.accounts.system_program.to_account_info(), + &crate::ID, + ) + .invoke_signed(&[ + crate::d7_infra_names::D7_CTOKEN_VAULT_SEED, + mint_key.as_ref(), + &[vault_bump], + ])?; + Ok(()) + } + + /// D7: All naming variants combined (payer + ctoken config/sponsor) + pub fn d7_all_names<'info>( + ctx: Context<'_, '_, '_, 'info, D7AllNames<'info>>, + params: D7AllNamesParams, + ) -> Result<()> { + use light_token_sdk::token::CreateTokenAccountCpi; + + // Set up the PDA record + ctx.accounts.d7_all_record.owner = params.owner; + + // Create token vault + let mint_key = ctx.accounts.mint.key(); + // Derive the vault bump at runtime + let (_, vault_bump) = Pubkey::find_program_address( + &[crate::d7_infra_names::D7_ALL_VAULT_SEED, mint_key.as_ref()], + &crate::ID, + ); + + CreateTokenAccountCpi { + payer: ctx.accounts.payer.to_account_info(), + account: ctx.accounts.d7_all_vault.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + owner: ctx.accounts.d7_all_authority.key(), + } + .rent_free( + ctx.accounts.ctoken_compressible_config.to_account_info(), + ctx.accounts.ctoken_rent_sponsor.to_account_info(), + ctx.accounts.system_program.to_account_info(), + &crate::ID, + ) + .invoke_signed(&[ + crate::d7_infra_names::D7_ALL_VAULT_SEED, + mint_key.as_ref(), + &[vault_bump], + ])?; + Ok(()) + } + + // ========================================================================= + // D9 Additional Seeds Tests + // ========================================================================= + + /// D9: Function call seed expression + pub fn d9_function_call<'info>( + ctx: Context<'_, '_, '_, 'info, D9FunctionCall<'info>>, + params: D9FunctionCallParams, + ) -> Result<()> { + ctx.accounts.d9_func_record.owner = params.key_a; + Ok(()) + } + + /// D9: All seed expression types (6 PDAs) + pub fn d9_all<'info>( + ctx: Context<'_, '_, '_, 'info, D9All<'info>>, + params: D9AllParams, + ) -> Result<()> { + ctx.accounts.d9_all_lit.owner = params.owner; + ctx.accounts.d9_all_const.owner = params.owner; + ctx.accounts.d9_all_ctx.owner = params.owner; + ctx.accounts.d9_all_param.owner = params.owner; + ctx.accounts.d9_all_bytes.owner = params.owner; + ctx.accounts.d9_all_func.owner = params.owner; + Ok(()) + } + + // ========================================================================= + // D5 Additional Markers Tests + // ========================================================================= + + /// D5: #[rentfree_token] attribute test + pub fn d5_rentfree_token<'info>( + ctx: Context<'_, '_, '_, 'info, D5RentfreeToken<'info>>, + params: D5RentfreeTokenParams, + ) -> Result<()> { + use light_token_sdk::token::CreateTokenAccountCpi; + + let mint_key = ctx.accounts.mint.key(); + CreateTokenAccountCpi { + payer: ctx.accounts.fee_payer.to_account_info(), + account: ctx.accounts.d5_token_vault.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + owner: ctx.accounts.vault_authority.key(), + } + .rent_free( + ctx.accounts.ctoken_compressible_config.to_account_info(), + ctx.accounts.ctoken_rent_sponsor.to_account_info(), + ctx.accounts.system_program.to_account_info(), + &crate::ID, + ) + .invoke_signed(&[ + crate::d5_markers::D5_VAULT_SEED, + mint_key.as_ref(), + &[params.vault_bump], + ])?; + Ok(()) + } + + /// D5: All markers combined (#[rentfree] + #[rentfree_token]) + pub fn d5_all_markers<'info>( + ctx: Context<'_, '_, '_, 'info, D5AllMarkers<'info>>, + params: D5AllMarkersParams, + ) -> Result<()> { + use light_token_sdk::token::CreateTokenAccountCpi; + + // Set up the PDA record + ctx.accounts.d5_all_record.owner = params.owner; + + // Create token vault + let mint_key = ctx.accounts.mint.key(); + // Derive the vault bump at runtime + let (_, vault_bump) = Pubkey::find_program_address( + &[crate::d5_markers::D5_ALL_VAULT_SEED, mint_key.as_ref()], + &crate::ID, + ); + + CreateTokenAccountCpi { + payer: ctx.accounts.fee_payer.to_account_info(), + account: ctx.accounts.d5_all_vault.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + owner: ctx.accounts.d5_all_authority.key(), + } + .rent_free( + ctx.accounts.ctoken_compressible_config.to_account_info(), + ctx.accounts.ctoken_rent_sponsor.to_account_info(), + ctx.accounts.system_program.to_account_info(), + &crate::ID, + ) + .invoke_signed(&[ + crate::d5_markers::D5_ALL_VAULT_SEED, + mint_key.as_ref(), + &[vault_bump], + ])?; + Ok(()) + } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/mod.rs index 8354724d0f..308d7eadc8 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/state/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/mod.rs @@ -26,7 +26,7 @@ pub struct UserRecord { pub category_id: u64, } -#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[derive(Default, Debug, PartialEq, InitSpace, RentFreeAccount)] #[compress_as(start_time = 0, end_time = None, score = 0)] #[account] pub struct GameSession { diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros.rs new file mode 100644 index 0000000000..cc572631ff --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros.rs @@ -0,0 +1,72 @@ +//! Unit tests for RentFreeAccount-derived traits +//! +//! Tests individual traits derived by the `RentFreeAccount` macro on account data structs. + +#[path = "account_macros/shared.rs"] +pub mod shared; + +#[path = "account_macros/d1_single_pubkey_test.rs"] +pub mod d1_single_pubkey_test; + +#[path = "account_macros/d1_multi_pubkey_test.rs"] +pub mod d1_multi_pubkey_test; + +#[path = "account_macros/d1_no_pubkey_test.rs"] +pub mod d1_no_pubkey_test; + +#[path = "account_macros/d1_option_primitive_test.rs"] +pub mod d1_option_primitive_test; + +#[path = "account_macros/d1_option_pubkey_test.rs"] +pub mod d1_option_pubkey_test; + +#[path = "account_macros/d1_non_copy_test.rs"] +pub mod d1_non_copy_test; + +#[path = "account_macros/d1_array_test.rs"] +pub mod d1_array_test; + +#[path = "account_macros/d1_all_field_types_test.rs"] +pub mod d1_all_field_types_test; + +#[path = "account_macros/d2_single_compress_as_test.rs"] +pub mod d2_single_compress_as_test; + +#[path = "account_macros/d2_multiple_compress_as_test.rs"] +pub mod d2_multiple_compress_as_test; + +#[path = "account_macros/d2_no_compress_as_test.rs"] +pub mod d2_no_compress_as_test; + +#[path = "account_macros/d2_option_none_compress_as_test.rs"] +pub mod d2_option_none_compress_as_test; + +#[path = "account_macros/d2_all_compress_as_test.rs"] +pub mod d2_all_compress_as_test; + +#[path = "account_macros/d4_minimal_test.rs"] +pub mod d4_minimal_test; + +#[path = "account_macros/d4_info_last_test.rs"] +pub mod d4_info_last_test; + +#[path = "account_macros/d4_large_test.rs"] +pub mod d4_large_test; + +#[path = "account_macros/d4_all_composition_test.rs"] +pub mod d4_all_composition_test; + +#[path = "account_macros/amm_pool_state_test.rs"] +pub mod amm_pool_state_test; + +#[path = "account_macros/amm_observation_state_test.rs"] +pub mod amm_observation_state_test; + +#[path = "account_macros/core_user_record_test.rs"] +pub mod core_user_record_test; + +#[path = "account_macros/core_game_session_test.rs"] +pub mod core_game_session_test; + +#[path = "account_macros/core_placeholder_record_test.rs"] +pub mod core_placeholder_record_test; diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/CLAUDE.md b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/CLAUDE.md new file mode 100644 index 0000000000..6674dfe507 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/CLAUDE.md @@ -0,0 +1,211 @@ +# Account Macros Test Directory + +This directory contains unit tests for trait implementations derived by the `#[derive(RentFreeAccount)]` and `#[derive(LightCompressible)]` macros on account data structs. + +## Test Coverage Requirement + +**Every account struct** with `#[derive(RentFreeAccount)]` or `#[derive(LightCompressible)]` **must have its own dedicated test file** in this directory. + +## Directory Structure + +``` +account_macros/ +├── CLAUDE.md # This documentation +├── shared.rs # Generic test helpers and CompressibleTestFactory trait +├── d1_single_pubkey_test.rs # Tests for SinglePubkeyRecord +├── d1_multi_pubkey_test.rs # Tests for MultiPubkeyRecord (TODO) +├── d1_no_pubkey_test.rs # Tests for NoPubkeyRecord (TODO) +└── ... # One test file per account struct +``` + +## File Naming Convention + +Test files follow the pattern: `{dimension}_{struct_descriptor}_test.rs` + +- **Dimension prefix** matches the source module (e.g., `d1_` for `d1_field_types/`) +- **Struct descriptor** is a snake_case description of the struct being tested +- **Suffix** is always `_test.rs` + +Examples: +| Account Struct | Source Module | Test File | +|----------------|---------------|-----------| +| `SinglePubkeyRecord` | `d1_field_types/single_pubkey.rs` | `d1_single_pubkey_test.rs` | +| `MultiPubkeyRecord` | `d1_field_types/multi_pubkey.rs` | `d1_multi_pubkey_test.rs` | +| `NoPubkeyRecord` | `d1_field_types/no_pubkey.rs` | `d1_no_pubkey_test.rs` | +| `CompressAsAbsentRecord` | `d2_compress_as/absent.rs` | `d2_compress_as_absent_test.rs` | + +## Required Test File Structure + +Each test file must contain three sections: + +### 1. Factory Implementation (Required) + +Implement `CompressibleTestFactory` for your struct: + +```rust +use super::shared::CompressibleTestFactory; + +impl CompressibleTestFactory for YourRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + // ... initialize all other fields with valid test values + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + // ... initialize all other fields with valid test values + } + } +} +``` + +### 2. Generic Tests via Macro (Required) + +Invoke the macro to generate 17 generic trait tests: + +```rust +use crate::generate_trait_tests; + +generate_trait_tests!(YourRecord); +``` + +This generates tests for: +- **LightDiscriminator** (4 tests): 8-byte length, non-zero, method matches constant, slice matches array +- **HasCompressionInfo** (6 tests): reference access, mutation, opt access, set_none, panic on None +- **CompressAs** (2 tests): sets compression_info to None, returns Cow::Owned +- **Size** (2 tests): positive value, deterministic +- **CompressedInitSpace** (1 test): includes discriminator +- **DataHasher** (3 tests): 32-byte output, deterministic, compression_info affects hash + +### 3. Struct-Specific Tests (Required) + +Tests that cannot be generic because they depend on the struct's specific fields: + +#### CompressAs Field Preservation Tests +```rust +#[test] +fn test_compress_as_preserves_other_fields() { + // Verify each field is preserved after compress_as() +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + // Verify compress_as() works when compression_info starts as None +} +``` + +#### DataHasher Field Sensitivity Tests +```rust +#[test] +fn test_hash_differs_for_different_{field_name}() { + // One test per non-compression_info field + // Verify changing that field changes the hash +} +``` + +#### Pack/Unpack Tests (if struct has direct Pubkey fields) + +**IMPORTANT**: Only direct `Pubkey` fields are converted to `u8` indices. `Option` fields are **NOT** converted - they remain as `Option` in the packed struct. + +```rust +#[test] +fn test_packed_struct_has_u8_{pubkey_field}() { + // Verify PackedX struct has u8 index for each direct Pubkey field + // Note: Option fields stay as Option +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + // Verify Pubkey -> u8 index conversion +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + // Same Pubkey packed twice gets same index +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + // Different Pubkeys get different indices +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + // Packed struct always has compression_info = None +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + // Verify pubkeys are stored in PackedAccounts +} + +#[test] +fn test_pack_index_assignment_order() { + // Verify sequential index assignment +} +``` + +## Checklist for Creating a New Test File + +When adding tests for a new account struct `MyNewRecord`: + +- [ ] Create test file: `{dimension}_{descriptor}_test.rs` +- [ ] Add imports: + ```rust + use super::shared::CompressibleTestFactory; + use crate::generate_trait_tests; + use csdk_anchor_full_derived_test::{PackedMyNewRecord, MyNewRecord}; + use light_hasher::{DataHasher, Sha256}; + use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, + }; + use solana_pubkey::Pubkey; + ``` +- [ ] Implement `CompressibleTestFactory` for `MyNewRecord` +- [ ] Add `generate_trait_tests!(MyNewRecord);` +- [ ] Add `test_compress_as_preserves_other_fields` +- [ ] Add `test_compress_as_when_compression_info_already_none` +- [ ] Add `test_hash_differs_for_different_{field}` for each non-compression_info field +- [ ] If struct has Pubkey fields, add all Pack/Unpack tests +- [ ] Register test file in `/tests/account_macros.rs`: + ```rust + #[path = "account_macros/{your_test_file}.rs"] + pub mod {your_module_name}; + ``` + +## Generic vs Struct-Specific Tests + +| Test Category | Generic (shared.rs) | Struct-Specific | +|---------------|---------------------|-----------------| +| LightDiscriminator | All 4 tests | None | +| HasCompressionInfo | All 6 tests | None | +| CompressAs | Basic 2 tests | Field preservation | +| Size | All 2 tests | None | +| CompressedInitSpace | All 1 test | None | +| DataHasher | Basic 3 tests | Field sensitivity | +| Pack/Unpack | None | All (struct-dependent) | + +## Running Tests + +```bash +# Run all account macro tests +cargo test -p csdk-anchor-full-derived-test --test account_macros + +# Run tests for a specific struct +cargo test -p csdk-anchor-full-derived-test --test account_macros d1_single_pubkey + +# Run a specific test +cargo test -p csdk-anchor-full-derived-test --test account_macros test_pack_converts_pubkey_to_index +``` + +## Test Dependencies + +Tests depend on: +- `light_hasher` - For `DataHasher`, `Sha256` +- `light_sdk` - For `CompressAs`, `CompressionInfo`, `Pack`, `PackedAccounts`, `Size`, etc. +- `solana_pubkey` - For `Pubkey` +- Account structs and Packed variants from `csdk_anchor_full_derived_test` diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/amm_observation_state_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/amm_observation_state_test.rs new file mode 100644 index 0000000000..fa7288df1c --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/amm_observation_state_test.rs @@ -0,0 +1,533 @@ +//! AMM ObservationState Tests: ObservationState trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `ObservationState`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedObservationState +//! +//! ObservationState has 1 Pubkey field (pool_id) and a nested array of Observation structs, +//! testing Pack/Unpack behavior with array fields and nested data structures. + +use csdk_anchor_full_derived_test::{Observation, ObservationState, PackedObservationState}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for ObservationState { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + initialized: false, + observation_index: 0, + pool_id: Pubkey::new_unique(), + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + initialized: false, + observation_index: 0, + pool_id: Pubkey::new_unique(), + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(ObservationState); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_pool_id() { + let pool_id = Pubkey::new_unique(); + + let observation_state = ObservationState { + compression_info: Some(CompressionInfo::default()), + initialized: true, + observation_index: 5, + pool_id, + observations: [ + Observation { + block_timestamp: 1000, + cumulative_token_0_price_x32: 100, + cumulative_token_1_price_x32: 200, + }, + Observation { + block_timestamp: 2000, + cumulative_token_0_price_x32: 300, + cumulative_token_1_price_x32: 400, + }, + ], + padding: [0u64; 4], + }; + + let compressed = observation_state.compress_as(); + let inner = compressed.into_owned(); + + assert_eq!(inner.pool_id, pool_id); + assert!(inner.initialized); + assert_eq!(inner.observation_index, 5); +} + +#[test] +fn test_compress_as_preserves_observation_data() { + let observation_state = ObservationState { + compression_info: Some(CompressionInfo::default()), + initialized: true, + observation_index: 1, + pool_id: Pubkey::new_unique(), + observations: [ + Observation { + block_timestamp: 1111, + cumulative_token_0_price_x32: 5000, + cumulative_token_1_price_x32: 6000, + }, + Observation { + block_timestamp: 2222, + cumulative_token_0_price_x32: 7000, + cumulative_token_1_price_x32: 8000, + }, + ], + padding: [10, 20, 30, 40], + }; + + let compressed = observation_state.compress_as(); + let inner = compressed.into_owned(); + + assert_eq!(inner.observations[0].block_timestamp, 1111); + assert_eq!(inner.observations[0].cumulative_token_0_price_x32, 5000); + assert_eq!(inner.observations[0].cumulative_token_1_price_x32, 6000); + assert_eq!(inner.observations[1].block_timestamp, 2222); + assert_eq!(inner.observations[1].cumulative_token_0_price_x32, 7000); + assert_eq!(inner.observations[1].cumulative_token_1_price_x32, 8000); + assert_eq!(inner.padding, [10, 20, 30, 40]); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_pool_id() { + let mut observation1 = ObservationState::without_compression_info(); + let mut observation2 = ObservationState::without_compression_info(); + + observation1.pool_id = Pubkey::new_unique(); + observation2.pool_id = Pubkey::new_unique(); + + let hash1 = observation1.hash::().expect("hash should succeed"); + let hash2 = observation2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different pool_id should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_initialized() { + let mut observation1 = ObservationState::without_compression_info(); + let mut observation2 = ObservationState::without_compression_info(); + + observation1.initialized = true; + observation2.initialized = false; + + let hash1 = observation1.hash::().expect("hash should succeed"); + let hash2 = observation2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different initialized should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_observation_index() { + let mut observation1 = ObservationState::without_compression_info(); + let mut observation2 = ObservationState::without_compression_info(); + + observation1.observation_index = 1; + observation2.observation_index = 2; + + let hash1 = observation1.hash::().expect("hash should succeed"); + let hash2 = observation2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different observation_index should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_observation_data() { + let mut observation1 = ObservationState::without_compression_info(); + let mut observation2 = ObservationState::without_compression_info(); + + observation1.observations[0].block_timestamp = 1000; + observation2.observations[0].block_timestamp = 2000; + + let hash1 = observation1.hash::().expect("hash should succeed"); + let hash2 = observation2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different observation data should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_pool_id_index() { + // ObservationState has 1 Pubkey field (pool_id), so PackedObservationState should have 1 u8 field + let packed = PackedObservationState { + compression_info: None, + initialized: false, + observation_index: 0, + pool_id: 0, + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + assert_eq!(packed.pool_id, 0u8); +} + +#[test] +fn test_pack_converts_pool_id_to_index() { + let pool_id = Pubkey::new_unique(); + + let observation_state = ObservationState { + compression_info: None, + initialized: true, + observation_index: 0, + pool_id, + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = observation_state.pack(&mut packed_accounts); + + // The pool_id should have been added to packed_accounts and assigned index 0 + assert_eq!(packed.pool_id, 0u8); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1); + assert_eq!(stored_pubkeys[0], pool_id); +} + +#[test] +fn test_pack_with_pre_existing_pubkeys() { + let pool_id = Pubkey::new_unique(); + + let observation_state = ObservationState { + compression_info: None, + initialized: false, + observation_index: 0, + pool_id, + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let mut packed_accounts = PackedAccounts::default(); + // Pre-insert another pubkey + packed_accounts.insert_or_get(Pubkey::new_unique()); + + let packed = observation_state.pack(&mut packed_accounts); + + // The pool_id should have been added and assigned index 1 (since index 0 is taken) + assert_eq!(packed.pool_id, 1u8); +} + +#[test] +fn test_pack_preserves_all_fields() { + let pool_id = Pubkey::new_unique(); + + let observation_state = ObservationState { + compression_info: None, + initialized: true, + observation_index: 42, + pool_id, + observations: [ + Observation { + block_timestamp: 1000, + cumulative_token_0_price_x32: 5000, + cumulative_token_1_price_x32: 6000, + }, + Observation { + block_timestamp: 2000, + cumulative_token_0_price_x32: 7000, + cumulative_token_1_price_x32: 8000, + }, + ], + padding: [111, 222, 333, 444], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = observation_state.pack(&mut packed_accounts); + + assert!(packed.initialized); + assert_eq!(packed.observation_index, 42); + assert_eq!(packed.observations[0].block_timestamp, 1000); + assert_eq!(packed.observations[0].cumulative_token_0_price_x32, 5000); + assert_eq!(packed.observations[0].cumulative_token_1_price_x32, 6000); + assert_eq!(packed.observations[1].block_timestamp, 2000); + assert_eq!(packed.observations[1].cumulative_token_0_price_x32, 7000); + assert_eq!(packed.observations[1].cumulative_token_1_price_x32, 8000); + assert_eq!(packed.padding, [111, 222, 333, 444]); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let observation_with_info = ObservationState { + compression_info: Some(CompressionInfo::default()), + initialized: false, + observation_index: 0, + pool_id: Pubkey::new_unique(), + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = observation_with_info.pack(&mut packed_accounts); + + assert!( + packed.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_different_pool_ids_get_different_indices() { + let pool_id1 = Pubkey::new_unique(); + let pool_id2 = Pubkey::new_unique(); + + let observation1 = ObservationState { + compression_info: None, + initialized: false, + observation_index: 0, + pool_id: pool_id1, + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let observation2 = ObservationState { + compression_info: None, + initialized: false, + observation_index: 0, + pool_id: pool_id2, + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = observation1.pack(&mut packed_accounts); + let packed2 = observation2.pack(&mut packed_accounts); + + // Different pool IDs should get different indices + assert_ne!( + packed1.pool_id, packed2.pool_id, + "different pool_ids should produce different indices" + ); +} + +#[test] +fn test_pack_reuses_same_pool_id_index() { + let pool_id = Pubkey::new_unique(); + + let observation1 = ObservationState { + compression_info: None, + initialized: false, + observation_index: 0, + pool_id, + observations: [ + Observation { + block_timestamp: 1000, + cumulative_token_0_price_x32: 100, + cumulative_token_1_price_x32: 200, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let observation2 = ObservationState { + compression_info: None, + initialized: true, + observation_index: 1, + pool_id, + observations: [ + Observation { + block_timestamp: 2000, + cumulative_token_0_price_x32: 300, + cumulative_token_1_price_x32: 400, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = observation1.pack(&mut packed_accounts); + let packed2 = observation2.pack(&mut packed_accounts); + + // Same pool_id should get same index + assert_eq!( + packed1.pool_id, packed2.pool_id, + "same pool_id should produce same index" + ); +} + +#[test] +fn test_pack_stores_pool_id_in_packed_accounts() { + let pool_id = Pubkey::new_unique(); + + let observation_state = ObservationState { + compression_info: None, + initialized: false, + observation_index: 0, + pool_id, + observations: [ + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + Observation { + block_timestamp: 0, + cumulative_token_0_price_x32: 0, + cumulative_token_1_price_x32: 0, + }, + ], + padding: [0u64; 4], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = observation_state.pack(&mut packed_accounts); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1, "should have 1 pubkey stored"); + assert_eq!( + stored_pubkeys[packed.pool_id as usize], pool_id, + "stored pubkey should match" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/amm_pool_state_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/amm_pool_state_test.rs new file mode 100644 index 0000000000..b0cdbd986b --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/amm_pool_state_test.rs @@ -0,0 +1,569 @@ +//! AMM PoolState Tests: PoolState trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `PoolState`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedPoolState +//! +//! PoolState has 10 Pubkey fields and multiple numeric fields, testing +//! comprehensive Pack/Unpack behavior with multiple pubkey indices. + +use csdk_anchor_full_derived_test::{PackedPoolState, PoolState}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for PoolState { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + amm_config: Pubkey::new_unique(), + pool_creator: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + amm_config: Pubkey::new_unique(), + pool_creator: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(PoolState); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_numeric_fields() { + let pool = PoolState { + compression_info: Some(CompressionInfo::default()), + amm_config: Pubkey::new_unique(), + pool_creator: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 42, + status: 1, + lp_mint_decimals: 8, + mint_0_decimals: 6, + mint_1_decimals: 9, + lp_supply: 1000000, + protocol_fees_token_0: 500, + protocol_fees_token_1: 600, + fund_fees_token_0: 100, + fund_fees_token_1: 200, + open_time: 1234567890, + recent_epoch: 500, + padding: [0u64; 1], + }; + + let compressed = pool.compress_as(); + let inner = compressed.into_owned(); + + assert_eq!(inner.auth_bump, 42); + assert_eq!(inner.status, 1); + assert_eq!(inner.lp_mint_decimals, 8); + assert_eq!(inner.mint_0_decimals, 6); + assert_eq!(inner.mint_1_decimals, 9); + assert_eq!(inner.lp_supply, 1000000); + assert_eq!(inner.protocol_fees_token_0, 500); + assert_eq!(inner.protocol_fees_token_1, 600); + assert_eq!(inner.fund_fees_token_0, 100); + assert_eq!(inner.fund_fees_token_1, 200); + assert_eq!(inner.open_time, 1234567890); + assert_eq!(inner.recent_epoch, 500); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_amm_config() { + let mut pool1 = PoolState::without_compression_info(); + let mut pool2 = PoolState::without_compression_info(); + + pool1.amm_config = Pubkey::new_unique(); + pool2.amm_config = Pubkey::new_unique(); + + let hash1 = pool1.hash::().expect("hash should succeed"); + let hash2 = pool2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different amm_config should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_lp_supply() { + let mut pool1 = PoolState::without_compression_info(); + let mut pool2 = PoolState::without_compression_info(); + + pool1.lp_supply = 1000000; + pool2.lp_supply = 2000000; + + let hash1 = pool1.hash::().expect("hash should succeed"); + let hash2 = pool2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different lp_supply should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_auth_bump() { + let mut pool1 = PoolState::without_compression_info(); + let mut pool2 = PoolState::without_compression_info(); + + pool1.auth_bump = 100; + pool2.auth_bump = 200; + + let hash1 = pool1.hash::().expect("hash should succeed"); + let hash2 = pool2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different auth_bump should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_open_time() { + let mut pool1 = PoolState::without_compression_info(); + let mut pool2 = PoolState::without_compression_info(); + + pool1.open_time = 1000; + pool2.open_time = 2000; + + let hash1 = pool1.hash::().expect("hash should succeed"); + let hash2 = pool2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different open_time should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_pubkey_indices() { + // PoolState has 10 Pubkey fields, so PackedPoolState should have 10 u8 fields + let packed = PackedPoolState { + compression_info: None, + amm_config: 0, + pool_creator: 1, + token_0_vault: 2, + token_1_vault: 3, + lp_mint: 4, + token_0_mint: 5, + token_1_mint: 6, + token_0_program: 7, + token_1_program: 8, + observation_key: 9, + auth_bump: 42, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 100, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + }; + + assert_eq!(packed.amm_config, 0u8); + assert_eq!(packed.pool_creator, 1u8); + assert_eq!(packed.observation_key, 9u8); + assert_eq!(packed.auth_bump, 42u8); +} + +#[test] +fn test_pack_converts_all_10_pubkeys_to_indices() { + let pubkeys = vec![ + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + ]; + + let pool = PoolState { + compression_info: None, + amm_config: pubkeys[0], + pool_creator: pubkeys[1], + token_0_vault: pubkeys[2], + token_1_vault: pubkeys[3], + lp_mint: pubkeys[4], + token_0_mint: pubkeys[5], + token_1_mint: pubkeys[6], + token_0_program: pubkeys[7], + token_1_program: pubkeys[8], + observation_key: pubkeys[9], + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = pool.pack(&mut packed_accounts); + + // All 10 pubkeys should have been added and assigned indices 0-9 + assert_eq!(packed.amm_config, 0u8); + assert_eq!(packed.pool_creator, 1u8); + assert_eq!(packed.token_0_vault, 2u8); + assert_eq!(packed.token_1_vault, 3u8); + assert_eq!(packed.lp_mint, 4u8); + assert_eq!(packed.token_0_mint, 5u8); + assert_eq!(packed.token_1_mint, 6u8); + assert_eq!(packed.token_0_program, 7u8); + assert_eq!(packed.token_1_program, 8u8); + assert_eq!(packed.observation_key, 9u8); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 10); + for (i, pubkey) in pubkeys.iter().enumerate() { + assert_eq!(stored_pubkeys[i], *pubkey); + } +} + +#[test] +fn test_pack_reuses_same_pubkey_indices() { + // If the same pubkey is used in multiple fields, it should get the same index + let shared_pubkey = Pubkey::new_unique(); + + let pool = PoolState { + compression_info: None, + amm_config: shared_pubkey, + pool_creator: shared_pubkey, + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = pool.pack(&mut packed_accounts); + + // Same pubkey should get same index + assert_eq!( + packed.amm_config, packed.pool_creator, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_preserves_numeric_fields() { + let pool = PoolState { + compression_info: None, + amm_config: Pubkey::new_unique(), + pool_creator: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 127, + status: 2, + lp_mint_decimals: 8, + mint_0_decimals: 6, + mint_1_decimals: 9, + lp_supply: 9999999, + protocol_fees_token_0: 444, + protocol_fees_token_1: 555, + fund_fees_token_0: 111, + fund_fees_token_1: 222, + open_time: 1700000000, + recent_epoch: 999, + padding: [42u64; 1], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = pool.pack(&mut packed_accounts); + + assert_eq!(packed.auth_bump, 127); + assert_eq!(packed.status, 2); + assert_eq!(packed.lp_mint_decimals, 8); + assert_eq!(packed.mint_0_decimals, 6); + assert_eq!(packed.mint_1_decimals, 9); + assert_eq!(packed.lp_supply, 9999999); + assert_eq!(packed.protocol_fees_token_0, 444); + assert_eq!(packed.protocol_fees_token_1, 555); + assert_eq!(packed.fund_fees_token_0, 111); + assert_eq!(packed.fund_fees_token_1, 222); + assert_eq!(packed.open_time, 1700000000); + assert_eq!(packed.recent_epoch, 999); + assert_eq!(packed.padding[0], 42); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let pool_with_info = PoolState { + compression_info: Some(CompressionInfo::default()), + amm_config: Pubkey::new_unique(), + pool_creator: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = pool_with_info.pack(&mut packed_accounts); + + assert!( + packed.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let pool1 = PoolState { + compression_info: None, + amm_config: Pubkey::new_unique(), + pool_creator: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + }; + + let pool2 = PoolState { + compression_info: None, + amm_config: Pubkey::new_unique(), + pool_creator: Pubkey::new_unique(), + token_0_vault: Pubkey::new_unique(), + token_1_vault: Pubkey::new_unique(), + lp_mint: Pubkey::new_unique(), + token_0_mint: Pubkey::new_unique(), + token_1_mint: Pubkey::new_unique(), + token_0_program: Pubkey::new_unique(), + token_1_program: Pubkey::new_unique(), + observation_key: Pubkey::new_unique(), + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = pool1.pack(&mut packed_accounts); + let packed2 = pool2.pack(&mut packed_accounts); + + // Different pubkeys should get different indices + assert_ne!( + packed1.amm_config, packed2.amm_config, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_stores_all_pubkeys_in_packed_accounts() { + let pubkeys = vec![ + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + ]; + + let pool = PoolState { + compression_info: None, + amm_config: pubkeys[0], + pool_creator: pubkeys[1], + token_0_vault: pubkeys[2], + token_1_vault: pubkeys[3], + lp_mint: pubkeys[4], + token_0_mint: pubkeys[5], + token_1_mint: pubkeys[6], + token_0_program: pubkeys[7], + token_1_program: pubkeys[8], + observation_key: pubkeys[9], + auth_bump: 0, + status: 0, + lp_mint_decimals: 9, + mint_0_decimals: 9, + mint_1_decimals: 6, + lp_supply: 0, + protocol_fees_token_0: 0, + protocol_fees_token_1: 0, + fund_fees_token_0: 0, + fund_fees_token_1: 0, + open_time: 0, + recent_epoch: 0, + padding: [0u64; 1], + }; + + let mut packed_accounts = PackedAccounts::default(); + let _packed = pool.pack(&mut packed_accounts); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 10, "should have 10 pubkeys stored"); + + // Verify each pubkey is stored at its index + for (i, expected_pubkey) in pubkeys.iter().enumerate() { + assert_eq!( + stored_pubkeys[i], *expected_pubkey, + "pubkey at index {} should match", + i + ); + } +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_game_session_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_game_session_test.rs new file mode 100644 index 0000000000..064ffd25de --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_game_session_test.rs @@ -0,0 +1,490 @@ +//! Core Tests: GameSession trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `GameSession`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedGameSession +//! +//! GameSession has #[compress_as(start_time = 0, end_time = None, score = 0)] +//! which overrides field values during compression. + +use csdk_anchor_full_derived_test::{GameSession, PackedGameSession}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for GameSession { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + session_id: 1, + player: Pubkey::new_unique(), + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + session_id: 1, + player: Pubkey::new_unique(), + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(GameSession); + +// ============================================================================= +// Struct-Specific CompressAs Tests with Overrides +// ============================================================================= + +#[test] +fn test_compress_as_overrides_start_time() { + let player = Pubkey::new_unique(); + + let record = GameSession { + compression_info: Some(CompressionInfo::default()), + session_id: 1, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let compressed = record.compress_as(); + assert_eq!( + compressed.start_time, 0, + "compress_as should override start_time to 0" + ); +} + +#[test] +fn test_compress_as_overrides_end_time() { + let player = Pubkey::new_unique(); + + let record = GameSession { + compression_info: Some(CompressionInfo::default()), + session_id: 1, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let compressed = record.compress_as(); + assert_eq!( + compressed.end_time, None, + "compress_as should override end_time to None" + ); +} + +#[test] +fn test_compress_as_overrides_score() { + let player = Pubkey::new_unique(); + + let record = GameSession { + compression_info: Some(CompressionInfo::default()), + session_id: 1, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let compressed = record.compress_as(); + assert_eq!( + compressed.score, 0, + "compress_as should override score to 0" + ); +} + +#[test] +fn test_compress_as_preserves_session_id() { + let player = Pubkey::new_unique(); + let session_id = 999u64; + + let record = GameSession { + compression_info: Some(CompressionInfo::default()), + session_id, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let compressed = record.compress_as(); + assert_eq!( + compressed.session_id, session_id, + "compress_as should preserve session_id" + ); +} + +#[test] +fn test_compress_as_preserves_player() { + let player = Pubkey::new_unique(); + + let record = GameSession { + compression_info: Some(CompressionInfo::default()), + session_id: 1, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let compressed = record.compress_as(); + assert_eq!( + compressed.player, player, + "compress_as should preserve player" + ); +} + +#[test] +fn test_compress_as_preserves_game_type() { + let player = Pubkey::new_unique(); + let game_type = "custom game".to_string(); + + let record = GameSession { + compression_info: Some(CompressionInfo::default()), + session_id: 1, + player, + game_type: game_type.clone(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let compressed = record.compress_as(); + assert_eq!( + compressed.game_type, game_type, + "compress_as should preserve game_type" + ); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_session_id() { + let player = Pubkey::new_unique(); + + let record1 = GameSession { + compression_info: None, + session_id: 1, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let record2 = GameSession { + compression_info: None, + session_id: 2, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different session_id should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_player() { + let record1 = GameSession { + compression_info: None, + session_id: 1, + player: Pubkey::new_unique(), + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let record2 = GameSession { + compression_info: None, + session_id: 1, + player: Pubkey::new_unique(), + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different player should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_game_type() { + let player = Pubkey::new_unique(); + + let record1 = GameSession { + compression_info: None, + session_id: 1, + player, + game_type: "game1".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let record2 = GameSession { + compression_info: None, + session_id: 1, + player, + game_type: "game2".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different game_type should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_player() { + // Verify PackedGameSession has the expected structure + // The Packed struct uses the same field name but changes type to u8 + let packed = PackedGameSession { + compression_info: None, + session_id: 1, + player: 0, + game_type: "test".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + assert_eq!(packed.player, 0u8); + assert_eq!(packed.session_id, 1u64); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let player = Pubkey::new_unique(); + let record = GameSession { + compression_info: None, + session_id: 1, + player, + game_type: "test game".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // The player should have been added to packed_accounts + // and packed.player should be the index (0 for first pubkey) + assert_eq!(packed.player, 0u8); + assert_eq!(packed.session_id, 1); + + let mut packed_accounts = PackedAccounts::default(); + packed_accounts.insert_or_get(Pubkey::new_unique()); + let packed = record.pack(&mut packed_accounts); + + // The player should have been added to packed_accounts + // and packed.player should be the index (1 for second pubkey) + assert_eq!(packed.player, 1u8); + assert_eq!(packed.session_id, 1); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let player = Pubkey::new_unique(); + + let record1 = GameSession { + compression_info: None, + session_id: 1, + player, + game_type: "game1".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let record2 = GameSession { + compression_info: None, + session_id: 2, + player, + game_type: "game2".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same pubkey should get same index + assert_eq!( + packed1.player, packed2.player, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = GameSession { + compression_info: None, + session_id: 1, + player: Pubkey::new_unique(), + game_type: "game1".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let record2 = GameSession { + compression_info: None, + session_id: 2, + player: Pubkey::new_unique(), + game_type: "game2".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Different pubkeys should get different indices + assert_ne!( + packed1.player, packed2.player, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = GameSession { + compression_info: Some(CompressionInfo::default()), + session_id: 1, + player: Pubkey::new_unique(), + game_type: "test".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let record_without_info = GameSession { + compression_info: None, + session_id: 2, + player: Pubkey::new_unique(), + game_type: "test".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + // Both packed structs should have compression_info = None + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None (even if input has Some)" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let player1 = Pubkey::new_unique(); + let player2 = Pubkey::new_unique(); + + let record1 = GameSession { + compression_info: None, + session_id: 1, + player: player1, + game_type: "game1".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let record2 = GameSession { + compression_info: None, + session_id: 2, + player: player2, + game_type: "game2".to_string(), + start_time: 100, + end_time: Some(200), + score: 50, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Verify pubkeys are stored and retrievable + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.player as usize], player1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.player as usize], player2, + "second pubkey should match" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_placeholder_record_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_placeholder_record_test.rs new file mode 100644 index 0000000000..e0ea23b2bc --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_placeholder_record_test.rs @@ -0,0 +1,399 @@ +//! Core Tests: PlaceholderRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `PlaceholderRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedPlaceholderRecord + +use csdk_anchor_full_derived_test::{PackedPlaceholderRecord, PlaceholderRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for PlaceholderRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(PlaceholderRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let owner = Pubkey::new_unique(); + let name = "test placeholder".to_string(); + let placeholder_id = 42u64; + let counter = 999u32; + + let record = PlaceholderRecord { + compression_info: Some(CompressionInfo::default()), + owner, + name: name.clone(), + placeholder_id, + counter, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.name, name); + assert_eq!(compressed.placeholder_id, placeholder_id); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let owner = Pubkey::new_unique(); + let name = "test placeholder".to_string(); + let placeholder_id = 5u64; + let counter = 123u32; + + let record = PlaceholderRecord { + compression_info: None, + owner, + name: name.clone(), + placeholder_id, + counter, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.name, name); + assert_eq!(compressed.placeholder_id, placeholder_id); + assert_eq!(compressed.counter, counter); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = PlaceholderRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 0, + }; + + let record2 = PlaceholderRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_name() { + let owner = Pubkey::new_unique(); + + let record1 = PlaceholderRecord { + compression_info: None, + owner, + name: "placeholder1".to_string(), + placeholder_id: 1, + counter: 0, + }; + + let record2 = PlaceholderRecord { + compression_info: None, + owner, + name: "placeholder2".to_string(), + placeholder_id: 1, + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different name should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_placeholder_id() { + let owner = Pubkey::new_unique(); + + let record1 = PlaceholderRecord { + compression_info: None, + owner, + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 0, + }; + + let record2 = PlaceholderRecord { + compression_info: None, + owner, + name: "test placeholder".to_string(), + placeholder_id: 2, + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different placeholder_id should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = PlaceholderRecord { + compression_info: None, + owner, + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 0, + }; + + let record2 = PlaceholderRecord { + compression_info: None, + owner, + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 1, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + // Verify PackedPlaceholderRecord has the expected structure + // The Packed struct uses the same field name but changes type to u8 + let packed = PackedPlaceholderRecord { + compression_info: None, + owner: 0, + name: "test".to_string(), + placeholder_id: 1, + counter: 42, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.placeholder_id, 1u64); + assert_eq!(packed.counter, 42u32); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = PlaceholderRecord { + compression_info: None, + owner, + name: "test placeholder".to_string(), + placeholder_id: 1, + counter: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (0 for first pubkey) + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.placeholder_id, 1); + assert_eq!(packed.counter, 100); + + let mut packed_accounts = PackedAccounts::default(); + packed_accounts.insert_or_get(Pubkey::new_unique()); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (1 for second pubkey) + assert_eq!(packed.owner, 1u8); + assert_eq!(packed.placeholder_id, 1); + assert_eq!(packed.counter, 100); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = PlaceholderRecord { + compression_info: None, + owner, + name: "placeholder1".to_string(), + placeholder_id: 1, + counter: 1, + }; + + let record2 = PlaceholderRecord { + compression_info: None, + owner, + name: "placeholder2".to_string(), + placeholder_id: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same pubkey should get same index + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = PlaceholderRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "placeholder1".to_string(), + placeholder_id: 1, + counter: 1, + }; + + let record2 = PlaceholderRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "placeholder2".to_string(), + placeholder_id: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Different pubkeys should get different indices + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = PlaceholderRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + name: "test".to_string(), + placeholder_id: 1, + counter: 100, + }; + + let record_without_info = PlaceholderRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test".to_string(), + placeholder_id: 2, + counter: 200, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + // Both packed structs should have compression_info = None + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None (even if input has Some)" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = PlaceholderRecord { + compression_info: None, + owner: owner1, + name: "placeholder1".to_string(), + placeholder_id: 1, + counter: 1, + }; + + let record2 = PlaceholderRecord { + compression_info: None, + owner: owner2, + name: "placeholder2".to_string(), + placeholder_id: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Verify pubkeys are stored and retrievable + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_user_record_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_user_record_test.rs new file mode 100644 index 0000000000..93eb0bbadd --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/core_user_record_test.rs @@ -0,0 +1,399 @@ +//! Core Tests: UserRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `UserRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedUserRecord + +use csdk_anchor_full_derived_test::{PackedUserRecord, UserRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for UserRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + name: "test user".to_string(), + score: 0, + category_id: 1, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test user".to_string(), + score: 0, + category_id: 1, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(UserRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let owner = Pubkey::new_unique(); + let name = "test user".to_string(); + let score = 999u64; + let category_id = 42u64; + + let record = UserRecord { + compression_info: Some(CompressionInfo::default()), + owner, + name: name.clone(), + score, + category_id, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.name, name); + assert_eq!(compressed.score, score); + assert_eq!(compressed.category_id, category_id); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let owner = Pubkey::new_unique(); + let name = "test user".to_string(); + let score = 123u64; + let category_id = 5u64; + + let record = UserRecord { + compression_info: None, + owner, + name: name.clone(), + score, + category_id, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.name, name); + assert_eq!(compressed.score, score); + assert_eq!(compressed.category_id, category_id); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = UserRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test user".to_string(), + score: 100, + category_id: 1, + }; + + let record2 = UserRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test user".to_string(), + score: 100, + category_id: 1, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_name() { + let owner = Pubkey::new_unique(); + + let record1 = UserRecord { + compression_info: None, + owner, + name: "user1".to_string(), + score: 100, + category_id: 1, + }; + + let record2 = UserRecord { + compression_info: None, + owner, + name: "user2".to_string(), + score: 100, + category_id: 1, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different name should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_score() { + let owner = Pubkey::new_unique(); + + let record1 = UserRecord { + compression_info: None, + owner, + name: "test user".to_string(), + score: 100, + category_id: 1, + }; + + let record2 = UserRecord { + compression_info: None, + owner, + name: "test user".to_string(), + score: 200, + category_id: 1, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different score should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_category_id() { + let owner = Pubkey::new_unique(); + + let record1 = UserRecord { + compression_info: None, + owner, + name: "test user".to_string(), + score: 100, + category_id: 1, + }; + + let record2 = UserRecord { + compression_info: None, + owner, + name: "test user".to_string(), + score: 100, + category_id: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different category_id should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + // Verify PackedUserRecord has the expected structure + // The Packed struct uses the same field name but changes type to u8 + let packed = PackedUserRecord { + compression_info: None, + owner: 0, + name: "test".to_string(), + score: 42, + category_id: 1, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.score, 42u64); + assert_eq!(packed.category_id, 1u64); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = UserRecord { + compression_info: None, + owner, + name: "test user".to_string(), + score: 100, + category_id: 1, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (0 for first pubkey) + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.score, 100); + assert_eq!(packed.category_id, 1); + + let mut packed_accounts = PackedAccounts::default(); + packed_accounts.insert_or_get(Pubkey::new_unique()); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (1 for second pubkey) + assert_eq!(packed.owner, 1u8); + assert_eq!(packed.score, 100); + assert_eq!(packed.category_id, 1); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = UserRecord { + compression_info: None, + owner, + name: "user1".to_string(), + score: 1, + category_id: 1, + }; + + let record2 = UserRecord { + compression_info: None, + owner, + name: "user2".to_string(), + score: 2, + category_id: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same pubkey should get same index + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = UserRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "user1".to_string(), + score: 1, + category_id: 1, + }; + + let record2 = UserRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "user2".to_string(), + score: 2, + category_id: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Different pubkeys should get different indices + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = UserRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + name: "test".to_string(), + score: 100, + category_id: 1, + }; + + let record_without_info = UserRecord { + compression_info: None, + owner: Pubkey::new_unique(), + name: "test".to_string(), + score: 200, + category_id: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + // Both packed structs should have compression_info = None + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None (even if input has Some)" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = UserRecord { + compression_info: None, + owner: owner1, + name: "user1".to_string(), + score: 1, + category_id: 1, + }; + + let record2 = UserRecord { + compression_info: None, + owner: owner2, + name: "user2".to_string(), + score: 2, + category_id: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Verify pubkeys are stored and retrievable + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_all_field_types_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_all_field_types_test.rs new file mode 100644 index 0000000000..5d71081a7f --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_all_field_types_test.rs @@ -0,0 +1,596 @@ +//! D1 Tests: AllFieldTypesRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `AllFieldTypesRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedAllFieldTypesRecord +//! +//! Comprehensive test exercising all field type code paths: +//! - Multiple Pubkeys (owner, delegate, authority) -> u8 indices +//! - Option (close_authority) -> remains Option (NOT converted to u8) +//! - String (name) -> clone() path +//! - Arrays (hash) -> direct copy +//! - Option (end_time, enabled) -> unchanged +//! - Regular primitives (counter, flag) -> direct copy + +use csdk_anchor_full_derived_test::{AllFieldTypesRecord, PackedAllFieldTypesRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for AllFieldTypesRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(Pubkey::new_unique()), + name: "test name".to_string(), + hash: [0u8; 32], + end_time: Some(1000), + enabled: Some(true), + counter: 0, + flag: false, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test name".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 0, + flag: false, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(AllFieldTypesRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_all_field_types() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let close_authority = Some(Pubkey::new_unique()); + let name = "Alice".to_string(); + let mut hash = [0u8; 32]; + hash[0] = 42; + let end_time = Some(5000u64); + let enabled = Some(false); + let counter = 999u64; + let flag = true; + + let record = AllFieldTypesRecord { + compression_info: Some(CompressionInfo::default()), + owner, + delegate, + authority, + close_authority, + name: name.clone(), + hash, + end_time, + enabled, + counter, + flag, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.delegate, delegate); + assert_eq!(compressed.authority, authority); + assert_eq!(compressed.close_authority, close_authority); + assert_eq!(compressed.name, name); + assert_eq!(compressed.hash, hash); + assert_eq!(compressed.end_time, end_time); + assert_eq!(compressed.enabled, enabled); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let name = "Bob".to_string(); + let counter = 123u64; + + let record = AllFieldTypesRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: None, + name: name.clone(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter, + flag: false, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve all fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.name, name); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_pubkey_field() { + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = AllFieldTypesRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate, + authority, + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let record2 = AllFieldTypesRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate, + authority, + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_option_pubkey_field() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: Some(Pubkey::new_unique()), + name: "test".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let record2 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different close_authority (Some vs None) should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_string_field() { + let owner = Pubkey::new_unique(); + + let record1 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "Alice".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let record2 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "Bob".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different name should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_array_field() { + let owner = Pubkey::new_unique(); + let mut hash1_array = [0u8; 32]; + hash1_array[0] = 1; + + let mut hash2_array = [0u8; 32]; + hash2_array[0] = 2; + + let record1 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test".to_string(), + hash: hash1_array, + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let record2 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test".to_string(), + hash: hash2_array, + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different hash array should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_option_primitive() { + let owner = Pubkey::new_unique(); + + let record1 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: Some(1000), + enabled: None, + counter: 100, + flag: false, + }; + + let record2 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: Some(2000), + enabled: None, + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different end_time should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_primitive() { + let owner = Pubkey::new_unique(); + + let record1 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let record2 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 200, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_all_types_converted() { + // Verify PackedAllFieldTypesRecord has the correct field types + // Note: Option is NOT converted to Option - it stays as Option + let close_authority = Pubkey::new_unique(); + let packed = PackedAllFieldTypesRecord { + compression_info: None, + owner: 0, + delegate: 1, + authority: 2, + close_authority: Some(close_authority), + name: "test".to_string(), + hash: [0u8; 32], + end_time: Some(1000), + enabled: Some(true), + counter: 42, + flag: false, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, 1u8); + assert_eq!(packed.authority, 2u8); + assert_eq!(packed.close_authority, Some(close_authority)); + assert_eq!(packed.name, "test".to_string()); + assert_eq!(packed.counter, 42u64); + assert!(!packed.flag); +} + +#[test] +fn test_pack_converts_all_pubkey_types() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let close_authority = Pubkey::new_unique(); + let name = "test".to_string(); + + let record = AllFieldTypesRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: Some(close_authority), + name: name.clone(), + hash: [0u8; 32], + end_time: Some(1000), + enabled: Some(true), + counter: 100, + flag: true, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Direct Pubkey fields are converted to u8 indices + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, 1u8); + assert_eq!(packed.authority, 2u8); + // Option is NOT converted to Option - it stays as Option + assert_eq!(packed.close_authority, Some(close_authority)); + assert_eq!(packed.name, name); + assert_eq!(packed.counter, 100); + assert!(packed.flag); + + // Only direct Pubkey fields are stored in packed_accounts (not Option) + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 3); + assert_eq!(stored_pubkeys[0], owner); + assert_eq!(stored_pubkeys[1], delegate); + assert_eq!(stored_pubkeys[2], authority); +} + +#[test] +fn test_pack_with_option_pubkey_none() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record = AllFieldTypesRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 100, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Only three pubkeys should have been added + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, 1u8); + assert_eq!(packed.authority, 2u8); + assert_eq!( + packed.close_authority, None, + "Option::None should remain None" + ); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 3); +} + +#[test] +fn test_pack_reuses_pubkey_indices() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: None, + name: "test1".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 1, + flag: false, + }; + + let record2 = AllFieldTypesRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: None, + name: "test2".to_string(), + hash: [0u8; 32], + end_time: None, + enabled: None, + counter: 2, + flag: true, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same pubkeys should get same indices + assert_eq!(packed1.owner, packed2.owner); + assert_eq!(packed1.delegate, packed2.delegate); + assert_eq!(packed1.authority, packed2.authority); + + // Should still only have 3 pubkeys total + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 3); +} + +#[test] +fn test_pack_preserves_non_pubkey_fields() { + let name = "AllFieldsTest".to_string(); + let mut hash = [0u8; 32]; + hash[0] = 99; + let end_time = Some(9999u64); + let enabled = Some(true); + let counter = 12345u64; + let flag = true; + + let record = AllFieldTypesRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: name.clone(), + hash, + end_time, + enabled, + counter, + flag, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // All non-Pubkey fields should be preserved + assert_eq!(packed.name, name); + assert_eq!(packed.hash, hash); + assert_eq!(packed.end_time, end_time); + assert_eq!(packed.enabled, enabled); + assert_eq!(packed.counter, counter); + assert_eq!(packed.flag, flag); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_array_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_array_test.rs new file mode 100644 index 0000000000..d7f1bc9970 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_array_test.rs @@ -0,0 +1,263 @@ +//! D1 Tests: ArrayRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `ArrayRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack (identity implementation with array fields) +//! +//! Note: Since ArrayRecord has no Pubkey fields, the Pack trait generates an identity +//! implementation where Packed = Self. Array fields are directly copied in pack/unpack. +//! Therefore, no Pack/Unpack tests are needed. + +use csdk_anchor_full_derived_test::ArrayRecord; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::compressible::{CompressAs, CompressionInfo}; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for ArrayRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + hash: [0u8; 32], + short_data: [0u8; 8], + counter: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + hash: [0u8; 32], + short_data: [0u8; 8], + counter: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(ArrayRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let mut hash = [0u8; 32]; + hash[0] = 1; + hash[31] = 255; + + let mut short_data = [0u8; 8]; + short_data[0] = 42; + short_data[7] = 99; + + let counter = 999u64; + + let record = ArrayRecord { + compression_info: Some(CompressionInfo::default()), + hash, + short_data, + counter, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.hash, hash); + assert_eq!(compressed.short_data, short_data); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let mut hash = [0u8; 32]; + hash[15] = 128; + + let mut short_data = [0u8; 8]; + short_data[3] = 77; + + let counter = 123u64; + + let record = ArrayRecord { + compression_info: None, + hash, + short_data, + counter, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.hash, hash); + assert_eq!(compressed.short_data, short_data); + assert_eq!(compressed.counter, counter); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let hash = [5u8; 32]; + let short_data = [10u8; 8]; + + let record1 = ArrayRecord { + compression_info: None, + hash, + short_data, + counter: 1, + }; + + let record2 = ArrayRecord { + compression_info: None, + hash, + short_data, + counter: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_hash_array() { + let mut hash1_array = [0u8; 32]; + hash1_array[0] = 1; + + let mut hash2_array = [0u8; 32]; + hash2_array[0] = 2; + + let short_data = [10u8; 8]; + + let record1 = ArrayRecord { + compression_info: None, + hash: hash1_array, + short_data, + counter: 100, + }; + + let record2 = ArrayRecord { + compression_info: None, + hash: hash2_array, + short_data, + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different hash array should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_short_data_array() { + let hash = [5u8; 32]; + + let mut short_data1 = [0u8; 8]; + short_data1[0] = 1; + + let mut short_data2 = [0u8; 8]; + short_data2[0] = 2; + + let record1 = ArrayRecord { + compression_info: None, + hash, + short_data: short_data1, + counter: 100, + }; + + let record2 = ArrayRecord { + compression_info: None, + hash, + short_data: short_data2, + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different short_data array should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_array_position() { + let short_data = [10u8; 8]; + + let mut hash1_array = [0u8; 32]; + hash1_array[0] = 5; + + let mut hash2_array = [0u8; 32]; + hash2_array[31] = 5; // same value, different position + + let record1 = ArrayRecord { + compression_info: None, + hash: hash1_array, + short_data, + counter: 100, + }; + + let record2 = ArrayRecord { + compression_info: None, + hash: hash2_array, + short_data, + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different array positions should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_zero_vs_nonzero_array() { + let zero_hash = [0u8; 32]; + let nonzero_hash = [1u8; 32]; + let short_data = [10u8; 8]; + + let record1 = ArrayRecord { + compression_info: None, + hash: zero_hash, + short_data, + counter: 100, + }; + + let record2 = ArrayRecord { + compression_info: None, + hash: nonzero_hash, + short_data, + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "zero vs non-zero array should produce different hash" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_multi_pubkey_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_multi_pubkey_test.rs new file mode 100644 index 0000000000..7019ee783c --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_multi_pubkey_test.rs @@ -0,0 +1,442 @@ +//! D1 Tests: MultiPubkeyRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `MultiPubkeyRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedMultiPubkeyRecord + +use csdk_anchor_full_derived_test::{MultiPubkeyRecord, PackedMultiPubkeyRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for MultiPubkeyRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + amount: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + amount: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(MultiPubkeyRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let amount = 999u64; + + let record = MultiPubkeyRecord { + compression_info: Some(CompressionInfo::default()), + owner, + delegate, + authority, + amount, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.delegate, delegate); + assert_eq!(compressed.authority, authority); + assert_eq!(compressed.amount, amount); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let amount = 123u64; + + let record = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority, + amount, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.delegate, delegate); + assert_eq!(compressed.authority, authority); + assert_eq!(compressed.amount, amount); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_amount() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority, + amount: 1, + }; + + let record2 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority, + amount: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different amount should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = MultiPubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate, + authority, + amount: 100, + }; + + let record2 = MultiPubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate, + authority, + amount: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_delegate() { + let owner = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority, + amount: 100, + }; + + let record2 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate: Pubkey::new_unique(), + authority, + amount: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different delegate should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_authority() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + + let record1 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority: Pubkey::new_unique(), + amount: 100, + }; + + let record2 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority: Pubkey::new_unique(), + amount: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different authority should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_indices() { + // Verify PackedMultiPubkeyRecord has three u8 index fields + let packed = PackedMultiPubkeyRecord { + compression_info: None, + owner: 0, + delegate: 1, + authority: 2, + amount: 42, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, 1u8); + assert_eq!(packed.authority, 2u8); + assert_eq!(packed.amount, 42u64); +} + +#[test] +fn test_pack_converts_all_pubkeys_to_indices() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority, + amount: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // All three Pubkeys should have been added and packed should have their indices + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, 1u8); + assert_eq!(packed.authority, 2u8); + assert_eq!(packed.amount, 100); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 3); + assert_eq!(stored_pubkeys[0], owner); + assert_eq!(stored_pubkeys[1], delegate); + assert_eq!(stored_pubkeys[2], authority); +} + +#[test] +fn test_pack_reuses_pubkey_indices() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority, + amount: 1, + }; + + let record2 = MultiPubkeyRecord { + compression_info: None, + owner, + delegate, + authority, + amount: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same pubkeys should get same indices + assert_eq!(packed1.owner, packed2.owner); + assert_eq!(packed1.delegate, packed2.delegate); + assert_eq!(packed1.authority, packed2.authority); + + // Should still only have 3 pubkeys total + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 3); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = MultiPubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + amount: 1, + }; + + let record2 = MultiPubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + amount: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Different pubkeys should get different indices + assert_ne!( + packed1.owner, packed2.owner, + "different owner pubkeys should produce different indices" + ); + assert_ne!( + packed1.delegate, packed2.delegate, + "different delegate pubkeys should produce different indices" + ); + assert_ne!( + packed1.authority, packed2.authority, + "different authority pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = MultiPubkeyRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + amount: 100, + }; + + let record_without_info = MultiPubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + amount: 200, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + // Both packed structs should have compression_info = None + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None (even if input has Some)" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_all_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let delegate1 = Pubkey::new_unique(); + let authority1 = Pubkey::new_unique(); + + let owner2 = Pubkey::new_unique(); + let delegate2 = Pubkey::new_unique(); + let authority2 = Pubkey::new_unique(); + + let record1 = MultiPubkeyRecord { + compression_info: None, + owner: owner1, + delegate: delegate1, + authority: authority1, + amount: 1, + }; + + let record2 = MultiPubkeyRecord { + compression_info: None, + owner: owner2, + delegate: delegate2, + authority: authority2, + amount: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Verify pubkeys are stored and retrievable + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 6, "should have 6 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first record owner should match" + ); + assert_eq!( + stored_pubkeys[packed1.delegate as usize], delegate1, + "first record delegate should match" + ); + assert_eq!( + stored_pubkeys[packed1.authority as usize], authority1, + "first record authority should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second record owner should match" + ); + assert_eq!( + stored_pubkeys[packed2.delegate as usize], delegate2, + "second record delegate should match" + ); + assert_eq!( + stored_pubkeys[packed2.authority as usize], authority2, + "second record authority should match" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_no_pubkey_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_no_pubkey_test.rs new file mode 100644 index 0000000000..6547c3e76a --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_no_pubkey_test.rs @@ -0,0 +1,169 @@ +//! D1 Tests: NoPubkeyRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `NoPubkeyRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack (identity implementation: PackedNoPubkeyRecord = NoPubkeyRecord) +//! +//! Note: Since NoPubkeyRecord has no Pubkey fields, the Pack trait generates an identity +//! implementation where Packed = Self. Therefore, no Pack/Unpack tests are needed - the +//! struct is packed as-is without transformation. + +use csdk_anchor_full_derived_test::NoPubkeyRecord; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::compressible::{CompressAs, CompressionInfo}; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for NoPubkeyRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + counter: 0, + flag: false, + value: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + counter: 0, + flag: false, + value: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(NoPubkeyRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let counter = 999u64; + let flag = true; + let value = 42u32; + + let record = NoPubkeyRecord { + compression_info: Some(CompressionInfo::default()), + counter, + flag, + value, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); + assert_eq!(compressed.value, value); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let counter = 123u64; + let flag = false; + let value = 789u32; + + let record = NoPubkeyRecord { + compression_info: None, + counter, + flag, + value, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); + assert_eq!(compressed.value, value); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let record1 = NoPubkeyRecord { + compression_info: None, + counter: 1, + flag: true, + value: 100, + }; + + let record2 = NoPubkeyRecord { + compression_info: None, + counter: 2, + flag: true, + value: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_flag() { + let record1 = NoPubkeyRecord { + compression_info: None, + counter: 100, + flag: true, + value: 50, + }; + + let record2 = NoPubkeyRecord { + compression_info: None, + counter: 100, + flag: false, + value: 50, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different flag should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_value() { + let record1 = NoPubkeyRecord { + compression_info: None, + counter: 100, + flag: true, + value: 1, + }; + + let record2 = NoPubkeyRecord { + compression_info: None, + counter: 100, + flag: true, + value: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different value should produce different hash" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_non_copy_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_non_copy_test.rs new file mode 100644 index 0000000000..9f28d3adbf --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_non_copy_test.rs @@ -0,0 +1,219 @@ +//! D1 Tests: NonCopyRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `NonCopyRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack (identity implementation with clone() path) +//! +//! Note: Since NonCopyRecord has no Pubkey fields, the Pack trait generates an identity +//! implementation where Packed = Self. String fields use the clone() code path in pack/unpack. +//! Therefore, no Pack/Unpack tests are needed. + +use csdk_anchor_full_derived_test::NonCopyRecord; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::compressible::{CompressAs, CompressionInfo}; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for NonCopyRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + name: "test name".to_string(), + description: "test description".to_string(), + counter: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + name: "test name".to_string(), + description: "test description".to_string(), + counter: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(NonCopyRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let name = "Alice".to_string(); + let description = "A test user".to_string(); + let counter = 999u64; + + let record = NonCopyRecord { + compression_info: Some(CompressionInfo::default()), + name: name.clone(), + description: description.clone(), + counter, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.name, name); + assert_eq!(compressed.description, description); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let name = "Bob".to_string(); + let description = "Another test user".to_string(); + let counter = 123u64; + + let record = NonCopyRecord { + compression_info: None, + name: name.clone(), + description: description.clone(), + counter, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.name, name); + assert_eq!(compressed.description, description); + assert_eq!(compressed.counter, counter); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let record1 = NonCopyRecord { + compression_info: None, + name: "test".to_string(), + description: "description".to_string(), + counter: 1, + }; + + let record2 = NonCopyRecord { + compression_info: None, + name: "test".to_string(), + description: "description".to_string(), + counter: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_name() { + let record1 = NonCopyRecord { + compression_info: None, + name: "Alice".to_string(), + description: "description".to_string(), + counter: 100, + }; + + let record2 = NonCopyRecord { + compression_info: None, + name: "Bob".to_string(), + description: "description".to_string(), + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different name should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_description() { + let record1 = NonCopyRecord { + compression_info: None, + name: "test".to_string(), + description: "first description".to_string(), + counter: 100, + }; + + let record2 = NonCopyRecord { + compression_info: None, + name: "test".to_string(), + description: "second description".to_string(), + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different description should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_string_length() { + let record1 = NonCopyRecord { + compression_info: None, + name: "a".to_string(), + description: "description".to_string(), + counter: 100, + }; + + let record2 = NonCopyRecord { + compression_info: None, + name: "aa".to_string(), + description: "description".to_string(), + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different string length should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_empty_vs_non_empty_string() { + let record1 = NonCopyRecord { + compression_info: None, + name: "".to_string(), + description: "description".to_string(), + counter: 100, + }; + + let record2 = NonCopyRecord { + compression_info: None, + name: "name".to_string(), + description: "description".to_string(), + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "empty vs non-empty string should produce different hash" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_option_primitive_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_option_primitive_test.rs new file mode 100644 index 0000000000..eec38f168d --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_option_primitive_test.rs @@ -0,0 +1,240 @@ +//! D1 Tests: OptionPrimitiveRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `OptionPrimitiveRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack (identity implementation: PackedOptionPrimitiveRecord = OptionPrimitiveRecord) +//! +//! Note: Since OptionPrimitiveRecord has no Pubkey fields, the Pack trait generates an identity +//! implementation where Packed = Self. Option types remain unchanged in the packed +//! struct (not converted to Option). Therefore, no Pack/Unpack tests are needed. + +use csdk_anchor_full_derived_test::OptionPrimitiveRecord; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::compressible::{CompressAs, CompressionInfo}; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for OptionPrimitiveRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + counter: 0, + end_time: Some(1000), + enabled: Some(true), + score: Some(50), + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + counter: 0, + end_time: None, + enabled: None, + score: None, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(OptionPrimitiveRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let counter = 999u64; + let end_time = Some(2000u64); + let enabled = Some(false); + let score = Some(100u32); + + let record = OptionPrimitiveRecord { + compression_info: Some(CompressionInfo::default()), + counter, + end_time, + enabled, + score, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.end_time, end_time); + assert_eq!(compressed.enabled, enabled); + assert_eq!(compressed.score, score); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let counter = 123u64; + let end_time = None; + let enabled = Some(true); + let score = None; + + let record = OptionPrimitiveRecord { + compression_info: None, + counter, + end_time, + enabled, + score, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.end_time, end_time); + assert_eq!(compressed.enabled, enabled); + assert_eq!(compressed.score, score); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let record1 = OptionPrimitiveRecord { + compression_info: None, + counter: 1, + end_time: Some(1000), + enabled: Some(true), + score: Some(50), + }; + + let record2 = OptionPrimitiveRecord { + compression_info: None, + counter: 2, + end_time: Some(1000), + enabled: Some(true), + score: Some(50), + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_end_time() { + let record1 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: Some(1000), + enabled: Some(true), + score: Some(50), + }; + + let record2 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: Some(2000), + enabled: Some(true), + score: Some(50), + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different end_time should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_enabled() { + let record1 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: Some(1000), + enabled: Some(true), + score: Some(50), + }; + + let record2 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: Some(1000), + enabled: Some(false), + score: Some(50), + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different enabled should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_score() { + let record1 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: Some(1000), + enabled: Some(true), + score: Some(50), + }; + + let record2 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: Some(1000), + enabled: Some(true), + score: Some(100), + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different score should produce different hash" + ); +} + +#[test] +fn test_hash_differs_when_option_is_none_vs_some() { + let record1 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: None, + enabled: Some(true), + score: Some(50), + }; + + let record2 = OptionPrimitiveRecord { + compression_info: None, + counter: 100, + end_time: Some(1000), + enabled: Some(true), + score: Some(50), + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "Option None vs Some should produce different hash" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_option_pubkey_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_option_pubkey_test.rs new file mode 100644 index 0000000000..86fb31a17f --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_option_pubkey_test.rs @@ -0,0 +1,432 @@ +//! D1 Tests: OptionPubkeyRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `OptionPubkeyRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedOptionPubkeyRecord +//! +//! IMPORTANT: Option fields are NOT converted to Option in the packed struct. +//! Only direct Pubkey fields (like `owner: Pubkey`) are converted to u8 indices. +//! Option fields remain as Option in the packed struct. + +use csdk_anchor_full_derived_test::OptionPubkeyRecord; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for OptionPubkeyRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Some(Pubkey::new_unique()), + close_authority: Some(Pubkey::new_unique()), + amount: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: None, + close_authority: None, + amount: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(OptionPubkeyRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let owner = Pubkey::new_unique(); + let delegate = Some(Pubkey::new_unique()); + let close_authority = Some(Pubkey::new_unique()); + let amount = 999u64; + + let record = OptionPubkeyRecord { + compression_info: Some(CompressionInfo::default()), + owner, + delegate, + close_authority, + amount, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.delegate, delegate); + assert_eq!(compressed.close_authority, close_authority); + assert_eq!(compressed.amount, amount); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let owner = Pubkey::new_unique(); + let delegate = Some(Pubkey::new_unique()); + let close_authority = None; + let amount = 123u64; + + let record = OptionPubkeyRecord { + compression_info: None, + owner, + delegate, + close_authority, + amount, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.delegate, delegate); + assert_eq!(compressed.close_authority, close_authority); + assert_eq!(compressed.amount, amount); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_amount() { + let owner = Pubkey::new_unique(); + + let record1 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(Pubkey::new_unique()), + close_authority: None, + amount: 1, + }; + + let record2 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(Pubkey::new_unique()), + close_authority: None, + amount: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different amount should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = OptionPubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: None, + close_authority: Some(Pubkey::new_unique()), + amount: 100, + }; + + let record2 = OptionPubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: None, + close_authority: Some(Pubkey::new_unique()), + amount: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_delegate() { + let owner = Pubkey::new_unique(); + + let record1 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(Pubkey::new_unique()), + close_authority: None, + amount: 100, + }; + + let record2 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(Pubkey::new_unique()), + close_authority: None, + amount: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different delegate should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_close_authority() { + let owner = Pubkey::new_unique(); + + let record1 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: None, + close_authority: Some(Pubkey::new_unique()), + amount: 100, + }; + + let record2 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: None, + close_authority: Some(Pubkey::new_unique()), + amount: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different close_authority should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_pack_converts_pubkey_fields_to_indices() { + // Verify that pack() converts Pubkey fields to u8 indices + // This test checks the Pack trait implementation + let owner = Pubkey::new_unique(); + let record = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: None, + close_authority: None, + amount: 42, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // The packed struct should have owner as u8 index (0 since it's first pubkey) + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, None); + assert_eq!(packed.close_authority, None); + assert_eq!(packed.amount, 42u64); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: None, + close_authority: None, + amount: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added and packed.owner should be the index (0 for first pubkey) + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, None); + assert_eq!(packed.close_authority, None); + assert_eq!(packed.amount, 100); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1); + assert_eq!(stored_pubkeys[0], owner); +} + +#[test] +fn test_pack_preserves_option_pubkey_as_option_pubkey() { + // Option fields are NOT converted to Option + // They remain as Option in the packed struct + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + + let record = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(delegate), + close_authority: None, + amount: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Direct Pubkey field is converted to u8 index + assert_eq!(packed.owner, 0u8); + // Option stays as Option - NOT converted to Option + assert_eq!(packed.delegate, Some(delegate)); + assert_eq!(packed.close_authority, None); + + // Only the direct Pubkey field (owner) is stored in packed_accounts + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1); + assert_eq!(stored_pubkeys[0], owner); +} + +#[test] +fn test_pack_option_pubkey_none_stays_none() { + // Option::None remains None in packed struct + let owner = Pubkey::new_unique(); + let close_authority = Pubkey::new_unique(); + + let record = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: None, + close_authority: Some(close_authority), + amount: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Direct Pubkey field is converted to u8 index + assert_eq!(packed.owner, 0u8); + // Option fields stay as Option - NOT converted to Option + assert_eq!(packed.delegate, None, "Option::None stays None"); + assert_eq!( + packed.close_authority, + Some(close_authority), + "Option::Some stays Some" + ); + + // Only the direct Pubkey field (owner) is stored in packed_accounts + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1); + assert_eq!(stored_pubkeys[0], owner); +} + +#[test] +fn test_pack_all_option_pubkeys_some() { + // Tests that Option fields with Some values are preserved as-is + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let close_authority = Pubkey::new_unique(); + + let record = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(delegate), + close_authority: Some(close_authority), + amount: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Direct Pubkey field is converted to u8 index + assert_eq!(packed.owner, 0u8); + // Option fields stay as Option + assert_eq!(packed.delegate, Some(delegate)); + assert_eq!(packed.close_authority, Some(close_authority)); + + // Only the direct Pubkey field (owner) is stored in packed_accounts + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1); + assert_eq!(stored_pubkeys[0], owner); +} + +#[test] +fn test_pack_all_option_pubkeys_none() { + let owner = Pubkey::new_unique(); + + let record = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: None, + close_authority: None, + amount: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Only owner should have been added + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, None); + assert_eq!(packed.close_authority, None); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1); + assert_eq!(stored_pubkeys[0], owner); +} + +#[test] +fn test_pack_reuses_same_pubkey_index_for_direct_fields() { + // Tests that the same Pubkey in the direct (non-Option) field gets the same index + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + + let record1 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(delegate), + close_authority: None, + amount: 1, + }; + + let record2 = OptionPubkeyRecord { + compression_info: None, + owner, + delegate: Some(delegate), + close_authority: None, + amount: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same direct Pubkey field should get same index + assert_eq!( + packed1.owner, packed2.owner, + "same owner should produce same index" + ); + // Option fields stay as Option (not converted to indices) + assert_eq!(packed1.delegate, packed2.delegate); + + // Only one pubkey stored (owner) since it's the only direct Pubkey field + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 1); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_single_pubkey_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_single_pubkey_test.rs new file mode 100644 index 0000000000..468a184aff --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d1_single_pubkey_test.rs @@ -0,0 +1,323 @@ +//! D1 Tests: SinglePubkeyRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `SinglePubkeyRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedSinglePubkeyRecord + +use csdk_anchor_full_derived_test::{PackedSinglePubkeyRecord, SinglePubkeyRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for SinglePubkeyRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + counter: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(SinglePubkeyRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let owner = Pubkey::new_unique(); + let counter = 999u64; + + let record = SinglePubkeyRecord { + compression_info: Some(CompressionInfo::default()), + owner, + counter, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let owner = Pubkey::new_unique(); + let counter = 123u64; + + let record = SinglePubkeyRecord { + compression_info: None, + owner, + counter, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = SinglePubkeyRecord { + compression_info: None, + owner, + counter: 1, + }; + + let record2 = SinglePubkeyRecord { + compression_info: None, + owner, + counter: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = SinglePubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 100, + }; + + let record2 = SinglePubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + // Verify PackedSinglePubkeyRecord has the expected structure + // The Packed struct uses the same field name but changes type to u8 + let packed = PackedSinglePubkeyRecord { + compression_info: None, + owner: 0, + counter: 42, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.counter, 42u64); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = SinglePubkeyRecord { + compression_info: None, + owner, + counter: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (0 for first pubkey) + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.counter, 100); + + let mut packed_accounts = PackedAccounts::default(); + packed_accounts.insert_or_get(Pubkey::new_unique()); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (0 for second pubkey) + assert_eq!(packed.owner, 1u8); + assert_eq!(packed.counter, 100); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = SinglePubkeyRecord { + compression_info: None, + owner, + counter: 1, + }; + + let record2 = SinglePubkeyRecord { + compression_info: None, + owner, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same pubkey should get same index + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = SinglePubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 1, + }; + + let record2 = SinglePubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Different pubkeys should get different indices + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + // Per the CompressiblePack design (see docs/rentfree.md lines 443-458), + // Pack always sets compression_info to None in the packed struct. + // This is intentional - compression_info is metadata for on-chain accounts, + // not needed in the compressed representation. + let record_with_info = SinglePubkeyRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + counter: 100, + }; + + let record_without_info = SinglePubkeyRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 200, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + // Both packed structs should have compression_info = None + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None (even if input has Some)" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = SinglePubkeyRecord { + compression_info: None, + owner: owner1, + counter: 1, + }; + + let record2 = SinglePubkeyRecord { + compression_info: None, + owner: owner2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Verify pubkeys are stored and retrievable + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} + +#[test] +fn test_pack_index_assignment_order() { + let mut packed_accounts = PackedAccounts::default(); + + // Pack records with unique pubkeys in sequence + let owners: Vec = (0..5).map(|_| Pubkey::new_unique()).collect(); + let mut indices = Vec::new(); + + for owner in &owners { + let record = SinglePubkeyRecord { + compression_info: None, + owner: *owner, + counter: 0, + }; + let packed = record.pack(&mut packed_accounts); + indices.push(packed.owner); + } + + // Verify indices are assigned sequentially: 0, 1, 2, 3, 4 + assert_eq!(indices, vec![0, 1, 2, 3, 4], "indices should be sequential"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_all_compress_as_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_all_compress_as_test.rs new file mode 100644 index 0000000000..ebf4f35d15 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_all_compress_as_test.rs @@ -0,0 +1,536 @@ +//! D2 Tests: AllCompressAsRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `AllCompressAsRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedAllCompressAsRecord + +use csdk_anchor_full_derived_test::{AllCompressAsRecord, PackedAllCompressAsRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for AllCompressAsRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + time: 999, + score: 999, + cached: 999, + end: Some(999), + counter: 0, + flag: false, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + time: 999, + score: 999, + cached: 999, + end: Some(999), + counter: 0, + flag: false, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(AllCompressAsRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_overrides_numeric_fields() { + let owner = Pubkey::new_unique(); + let counter = 100u64; + let flag = true; + + let record = AllCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + time: 888, // Original value + score: 777, // Original value + cached: 666, // Original value + end: Some(999), + counter, + flag, + }; + + let compressed = record.compress_as(); + + // Per #[compress_as(time = 0, score = 0, cached = 0)]: + assert_eq!(compressed.time, 0, "time should be 0 after compress_as"); + assert_eq!(compressed.score, 0, "score should be 0 after compress_as"); + assert_eq!(compressed.cached, 0, "cached should be 0 after compress_as"); + + // Other fields should be preserved + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); +} + +#[test] +fn test_compress_as_overrides_option_to_none() { + let owner = Pubkey::new_unique(); + let counter = 100u64; + + let record = AllCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + time: 100, + score: 100, + cached: 100, + end: Some(999), // Original value + counter, + flag: false, + }; + + let compressed = record.compress_as(); + + // Per #[compress_as(end = None)]: + assert_eq!(compressed.end, None, "end should be None after compress_as"); + + // Other fields should be correct + assert_eq!(compressed.time, 0); + assert_eq!(compressed.score, 0); + assert_eq!(compressed.cached, 0); +} + +#[test] +fn test_compress_as_preserves_non_overridden_fields() { + let owner = Pubkey::new_unique(); + let counter = 555u64; + let flag = true; + + let record = AllCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + time: 100, + score: 200, + cached: 300, + end: Some(400), + counter, + flag, + }; + + let compressed = record.compress_as(); + + // counter and flag have no compress_as override, should be preserved + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); + assert_eq!(compressed.owner, owner); +} + +#[test] +fn test_compress_as_all_overrides_together() { + let owner = Pubkey::new_unique(); + let counter = 777u64; + let flag = false; + + let record = AllCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + time: u64::MAX, + score: u64::MAX, + cached: u64::MAX, + end: Some(u64::MAX), + counter, + flag, + }; + + let compressed = record.compress_as(); + + // All overridden fields should be at their override values + assert_eq!(compressed.time, 0); + assert_eq!(compressed.score, 0); + assert_eq!(compressed.cached, 0); + assert_eq!(compressed.end, None); + + // Non-overridden fields should be preserved + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = AllCompressAsRecord { + compression_info: None, + owner, + time: 0, + score: 0, + cached: 0, + end: None, + counter: 1, + flag: false, + }; + + let record2 = AllCompressAsRecord { + compression_info: None, + owner, + time: 0, + score: 0, + cached: 0, + end: None, + counter: 2, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_flag() { + let owner = Pubkey::new_unique(); + + let record1 = AllCompressAsRecord { + compression_info: None, + owner, + time: 0, + score: 0, + cached: 0, + end: None, + counter: 0, + flag: true, + }; + + let record2 = AllCompressAsRecord { + compression_info: None, + owner, + time: 0, + score: 0, + cached: 0, + end: None, + counter: 0, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different flag should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_time() { + let owner = Pubkey::new_unique(); + + let record1 = AllCompressAsRecord { + compression_info: None, + owner, + time: 1, + score: 0, + cached: 0, + end: None, + counter: 0, + flag: false, + }; + + let record2 = AllCompressAsRecord { + compression_info: None, + owner, + time: 2, + score: 0, + cached: 0, + end: None, + counter: 0, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different time should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = AllCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + time: 100, + score: 100, + cached: 100, + end: Some(100), + counter: 100, + flag: false, + }; + + let record2 = AllCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + time: 100, + score: 100, + cached: 100, + end: Some(100), + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + let packed = PackedAllCompressAsRecord { + compression_info: None, + owner: 0, + time: 42, + score: 43, + cached: 44, + end: None, + counter: 100, + flag: true, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.time, 42u64); + assert_eq!(packed.score, 43u64); + assert_eq!(packed.cached, 44u64); + assert_eq!(packed.end, None); + assert_eq!(packed.counter, 100u64); + assert!(packed.flag); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = AllCompressAsRecord { + compression_info: None, + owner, + time: 50, + score: 60, + cached: 70, + end: Some(80), + counter: 100, + flag: true, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.time, 50); + assert_eq!(packed.score, 60); + assert_eq!(packed.cached, 70); + assert_eq!(packed.end, Some(80)); + assert_eq!(packed.counter, 100); + assert!(packed.flag); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = AllCompressAsRecord { + compression_info: None, + owner, + time: 1, + score: 1, + cached: 1, + end: Some(1), + counter: 1, + flag: true, + }; + + let record2 = AllCompressAsRecord { + compression_info: None, + owner, + time: 2, + score: 2, + cached: 2, + end: Some(2), + counter: 2, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = AllCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + time: 1, + score: 1, + cached: 1, + end: Some(1), + counter: 1, + flag: true, + }; + + let record2 = AllCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + time: 2, + score: 2, + cached: 2, + end: Some(2), + counter: 2, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = AllCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + time: 100, + score: 100, + cached: 100, + end: Some(100), + counter: 100, + flag: true, + }; + + let record_without_info = AllCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + time: 200, + score: 200, + cached: 200, + end: Some(200), + counter: 200, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = AllCompressAsRecord { + compression_info: None, + owner: owner1, + time: 1, + score: 1, + cached: 1, + end: Some(1), + counter: 1, + flag: true, + }; + + let record2 = AllCompressAsRecord { + compression_info: None, + owner: owner2, + time: 2, + score: 2, + cached: 2, + end: Some(2), + counter: 2, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} + +#[test] +fn test_pack_index_assignment_order() { + let mut packed_accounts = PackedAccounts::default(); + + let owners: Vec = (0..5).map(|_| Pubkey::new_unique()).collect(); + let mut indices = Vec::new(); + + for owner in &owners { + let record = AllCompressAsRecord { + compression_info: None, + owner: *owner, + time: 0, + score: 0, + cached: 0, + end: None, + counter: 0, + flag: false, + }; + let packed = record.pack(&mut packed_accounts); + indices.push(packed.owner); + } + + assert_eq!(indices, vec![0, 1, 2, 3, 4], "indices should be sequential"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_multiple_compress_as_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_multiple_compress_as_test.rs new file mode 100644 index 0000000000..df1d5c07a1 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_multiple_compress_as_test.rs @@ -0,0 +1,454 @@ +//! D2 Tests: MultipleCompressAsRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `MultipleCompressAsRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedMultipleCompressAsRecord + +use csdk_anchor_full_derived_test::{MultipleCompressAsRecord, PackedMultipleCompressAsRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for MultipleCompressAsRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + start: 999, + score: 999, + cached: 999, + counter: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + start: 999, + score: 999, + cached: 999, + counter: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(MultipleCompressAsRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_overrides_all_marked_fields() { + let owner = Pubkey::new_unique(); + let counter = 100u64; + + let record = MultipleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + start: 888, // Original value + score: 777, // Original value + cached: 666, // Original value + counter, + }; + + let compressed = record.compress_as(); + + // Per #[compress_as(start = 0, score = 0, cached = 0)]: + assert_eq!(compressed.start, 0, "start should be 0 after compress_as"); + assert_eq!(compressed.score, 0, "score should be 0 after compress_as"); + assert_eq!(compressed.cached, 0, "cached should be 0 after compress_as"); + + // Fields without compress_as override should be preserved + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_preserves_non_overridden_fields() { + let owner = Pubkey::new_unique(); + let counter = 555u64; + + let record = MultipleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + start: 100, + score: 200, + cached: 300, + counter, + }; + + let compressed = record.compress_as(); + + // counter has no compress_as override, should be preserved + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.owner, owner); +} + +#[test] +fn test_compress_as_with_all_max_values() { + let owner = Pubkey::new_unique(); + + let record = MultipleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + start: u64::MAX, + score: u64::MAX, + cached: u64::MAX, + counter: u64::MAX, + }; + + let compressed = record.compress_as(); + + // Overridden fields should still be 0 + assert_eq!(compressed.start, 0); + assert_eq!(compressed.score, 0); + assert_eq!(compressed.cached, 0); + // Non-overridden fields should be preserved + assert_eq!(compressed.counter, u64::MAX); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 0, + score: 0, + cached: 0, + counter: 1, + }; + + let record2 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 0, + score: 0, + cached: 0, + counter: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_start() { + let owner = Pubkey::new_unique(); + + let record1 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 1, + score: 0, + cached: 0, + counter: 0, + }; + + let record2 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 2, + score: 0, + cached: 0, + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different start should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_score() { + let owner = Pubkey::new_unique(); + + let record1 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 0, + score: 1, + cached: 0, + counter: 0, + }; + + let record2 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 0, + score: 2, + cached: 0, + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different score should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = MultipleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start: 100, + score: 100, + cached: 100, + counter: 100, + }; + + let record2 = MultipleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start: 100, + score: 100, + cached: 100, + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + let packed = PackedMultipleCompressAsRecord { + compression_info: None, + owner: 0, + start: 42, + score: 43, + cached: 44, + counter: 100, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.start, 42u64); + assert_eq!(packed.score, 43u64); + assert_eq!(packed.cached, 44u64); + assert_eq!(packed.counter, 100u64); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 50, + score: 60, + cached: 70, + counter: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.start, 50); + assert_eq!(packed.score, 60); + assert_eq!(packed.cached, 70); + assert_eq!(packed.counter, 100); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 1, + score: 1, + cached: 1, + counter: 1, + }; + + let record2 = MultipleCompressAsRecord { + compression_info: None, + owner, + start: 2, + score: 2, + cached: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = MultipleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start: 1, + score: 1, + cached: 1, + counter: 1, + }; + + let record2 = MultipleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start: 2, + score: 2, + cached: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = MultipleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + start: 100, + score: 100, + cached: 100, + counter: 100, + }; + + let record_without_info = MultipleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start: 200, + score: 200, + cached: 200, + counter: 200, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = MultipleCompressAsRecord { + compression_info: None, + owner: owner1, + start: 1, + score: 1, + cached: 1, + counter: 1, + }; + + let record2 = MultipleCompressAsRecord { + compression_info: None, + owner: owner2, + start: 2, + score: 2, + cached: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} + +#[test] +fn test_pack_index_assignment_order() { + let mut packed_accounts = PackedAccounts::default(); + + let owners: Vec = (0..5).map(|_| Pubkey::new_unique()).collect(); + let mut indices = Vec::new(); + + for owner in &owners { + let record = MultipleCompressAsRecord { + compression_info: None, + owner: *owner, + start: 0, + score: 0, + cached: 0, + counter: 0, + }; + let packed = record.pack(&mut packed_accounts); + indices.push(packed.owner); + } + + assert_eq!(indices, vec![0, 1, 2, 3, 4], "indices should be sequential"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_no_compress_as_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_no_compress_as_test.rs new file mode 100644 index 0000000000..dbe1f6cf89 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_no_compress_as_test.rs @@ -0,0 +1,370 @@ +//! D2 Tests: NoCompressAsRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `NoCompressAsRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedNoCompressAsRecord + +use csdk_anchor_full_derived_test::{NoCompressAsRecord, PackedNoCompressAsRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for NoCompressAsRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + counter: 0, + flag: false, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 0, + flag: false, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(NoCompressAsRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_all_fields() { + let owner = Pubkey::new_unique(); + let counter = 123u64; + let flag = true; + + let record = NoCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + counter, + flag, + }; + + let compressed = record.compress_as(); + + // No compress_as attribute, all fields should be preserved + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); +} + +#[test] +fn test_compress_as_with_multiple_flag_values() { + let owner = Pubkey::new_unique(); + let counter = 555u64; + + for flag_val in &[true, false] { + let record = NoCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + counter, + flag: *flag_val, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.flag, *flag_val, "flag should be preserved"); + assert_eq!(compressed.counter, counter, "counter should be preserved"); + } +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let owner = Pubkey::new_unique(); + let counter = 789u64; + let flag = true; + + let record = NoCompressAsRecord { + compression_info: None, + owner, + counter, + flag, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve all fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = NoCompressAsRecord { + compression_info: None, + owner, + counter: 1, + flag: false, + }; + + let record2 = NoCompressAsRecord { + compression_info: None, + owner, + counter: 2, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_flag() { + let owner = Pubkey::new_unique(); + + let record1 = NoCompressAsRecord { + compression_info: None, + owner, + counter: 100, + flag: true, + }; + + let record2 = NoCompressAsRecord { + compression_info: None, + owner, + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different flag should produce different hash"); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = NoCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 100, + flag: false, + }; + + let record2 = NoCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 100, + flag: false, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + let packed = PackedNoCompressAsRecord { + compression_info: None, + owner: 0, + counter: 42, + flag: true, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.counter, 42u64); + assert!(packed.flag); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = NoCompressAsRecord { + compression_info: None, + owner, + counter: 100, + flag: true, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.counter, 100); + assert!(packed.flag); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = NoCompressAsRecord { + compression_info: None, + owner, + counter: 1, + flag: true, + }; + + let record2 = NoCompressAsRecord { + compression_info: None, + owner, + counter: 2, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = NoCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 1, + flag: true, + }; + + let record2 = NoCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 2, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = NoCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + counter: 100, + flag: true, + }; + + let record_without_info = NoCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + counter: 200, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = NoCompressAsRecord { + compression_info: None, + owner: owner1, + counter: 1, + flag: true, + }; + + let record2 = NoCompressAsRecord { + compression_info: None, + owner: owner2, + counter: 2, + flag: false, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} + +#[test] +fn test_pack_index_assignment_order() { + let mut packed_accounts = PackedAccounts::default(); + + let owners: Vec = (0..5).map(|_| Pubkey::new_unique()).collect(); + let mut indices = Vec::new(); + + for owner in &owners { + let record = NoCompressAsRecord { + compression_info: None, + owner: *owner, + counter: 0, + flag: false, + }; + let packed = record.pack(&mut packed_accounts); + indices.push(packed.owner); + } + + assert_eq!(indices, vec![0, 1, 2, 3, 4], "indices should be sequential"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_option_none_compress_as_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_option_none_compress_as_test.rs new file mode 100644 index 0000000000..84e0ce5131 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_option_none_compress_as_test.rs @@ -0,0 +1,456 @@ +//! D2 Tests: OptionNoneCompressAsRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `OptionNoneCompressAsRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedOptionNoneCompressAsRecord + +use csdk_anchor_full_derived_test::{OptionNoneCompressAsRecord, PackedOptionNoneCompressAsRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for OptionNoneCompressAsRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + start_time: 0, + end_time: Some(999), + counter: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + start_time: 0, + end_time: Some(999), + counter: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(OptionNoneCompressAsRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_overrides_end_time_to_none() { + let owner = Pubkey::new_unique(); + let start_time = 100u64; + let counter = 50u64; + + let record = OptionNoneCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + start_time, + end_time: Some(999), // Original value + counter, + }; + + let compressed = record.compress_as(); + + // Per #[compress_as(end_time = None)], end_time should be None in compressed form + assert_eq!( + compressed.end_time, None, + "end_time should be None after compress_as" + ); + // Other fields should be preserved + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.start_time, start_time); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_with_end_time_already_none() { + let owner = Pubkey::new_unique(); + let start_time = 200u64; + let counter = 75u64; + + let record = OptionNoneCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + start_time, + end_time: None, // Already None + counter, + }; + + let compressed = record.compress_as(); + + // Should remain None + assert_eq!(compressed.end_time, None); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.start_time, start_time); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_preserves_start_time_and_counter() { + let owner = Pubkey::new_unique(); + let start_time = 555u64; + let counter = 777u64; + + let record = OptionNoneCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + start_time, + end_time: Some(u64::MAX), + counter, + }; + + let compressed = record.compress_as(); + + // start_time and counter have no compress_as override, should be preserved + assert_eq!(compressed.start_time, start_time); + assert_eq!(compressed.counter, counter); + // end_time should be None + assert_eq!(compressed.end_time, None); +} + +#[test] +fn test_compress_as_with_various_end_time_values() { + let owner = Pubkey::new_unique(); + + for end_val in &[Some(0u64), Some(100), Some(999), Some(u64::MAX), None] { + let record = OptionNoneCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + start_time: 0, + end_time: *end_val, + counter: 0, + }; + + let compressed = record.compress_as(); + // All should compress end_time to None + assert_eq!( + compressed.end_time, None, + "end_time should always be None after compress_as" + ); + } +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 0, + end_time: None, + counter: 1, + }; + + let record2 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 0, + end_time: None, + counter: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_start_time() { + let owner = Pubkey::new_unique(); + + let record1 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 1, + end_time: None, + counter: 0, + }; + + let record2 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 2, + end_time: None, + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different start_time should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_end_time() { + let owner = Pubkey::new_unique(); + + let record1 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 0, + end_time: Some(1), + counter: 0, + }; + + let record2 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 0, + end_time: Some(2), + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different end_time should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = OptionNoneCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start_time: 100, + end_time: Some(100), + counter: 100, + }; + + let record2 = OptionNoneCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start_time: 100, + end_time: Some(100), + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + let packed = PackedOptionNoneCompressAsRecord { + compression_info: None, + owner: 0, + start_time: 42, + end_time: None, + counter: 100, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.start_time, 42u64); + assert_eq!(packed.end_time, None); + assert_eq!(packed.counter, 100u64); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 50, + end_time: Some(100), + counter: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.start_time, 50); + assert_eq!(packed.end_time, Some(100)); + assert_eq!(packed.counter, 100); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 1, + end_time: Some(1), + counter: 1, + }; + + let record2 = OptionNoneCompressAsRecord { + compression_info: None, + owner, + start_time: 2, + end_time: Some(2), + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = OptionNoneCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start_time: 1, + end_time: Some(1), + counter: 1, + }; + + let record2 = OptionNoneCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start_time: 2, + end_time: Some(2), + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = OptionNoneCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + start_time: 100, + end_time: Some(100), + counter: 100, + }; + + let record_without_info = OptionNoneCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + start_time: 200, + end_time: Some(200), + counter: 200, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = OptionNoneCompressAsRecord { + compression_info: None, + owner: owner1, + start_time: 1, + end_time: Some(1), + counter: 1, + }; + + let record2 = OptionNoneCompressAsRecord { + compression_info: None, + owner: owner2, + start_time: 2, + end_time: Some(2), + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} + +#[test] +fn test_pack_index_assignment_order() { + let mut packed_accounts = PackedAccounts::default(); + + let owners: Vec = (0..5).map(|_| Pubkey::new_unique()).collect(); + let mut indices = Vec::new(); + + for owner in &owners { + let record = OptionNoneCompressAsRecord { + compression_info: None, + owner: *owner, + start_time: 0, + end_time: None, + counter: 0, + }; + let packed = record.pack(&mut packed_accounts); + indices.push(packed.owner); + } + + assert_eq!(indices, vec![0, 1, 2, 3, 4], "indices should be sequential"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_single_compress_as_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_single_compress_as_test.rs new file mode 100644 index 0000000000..4a5246d45b --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d2_single_compress_as_test.rs @@ -0,0 +1,369 @@ +//! D2 Tests: SingleCompressAsRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `SingleCompressAsRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedSingleCompressAsRecord + +use csdk_anchor_full_derived_test::{PackedSingleCompressAsRecord, SingleCompressAsRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for SingleCompressAsRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + cached: 999, + counter: 0, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + cached: 999, + counter: 0, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(SingleCompressAsRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_overrides_cached_to_zero() { + let owner = Pubkey::new_unique(); + let counter = 100u64; + + let record = SingleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + cached: 999, // Original value + counter, + }; + + let compressed = record.compress_as(); + // Per #[compress_as(cached = 0)], cached should be 0 in compressed form + assert_eq!(compressed.cached, 0, "cached should be 0 after compress_as"); + // Other fields should be preserved + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_preserves_counter() { + let owner = Pubkey::new_unique(); + let counter = 555u64; + + let record = SingleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + cached: 999, + counter, + }; + + let compressed = record.compress_as(); + // counter has no compress_as override, should be preserved + assert_eq!(compressed.counter, counter); +} + +#[test] +fn test_compress_as_with_multiple_cached_values() { + let owner = Pubkey::new_unique(); + + for cached_val in &[0u64, 100, 999, u64::MAX] { + let record = SingleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner, + cached: *cached_val, + counter: 0, + }; + + let compressed = record.compress_as(); + // All should compress cached to 0 + assert_eq!( + compressed.cached, 0, + "cached should always be 0 after compress_as" + ); + } +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = SingleCompressAsRecord { + compression_info: None, + owner, + cached: 0, + counter: 1, + }; + + let record2 = SingleCompressAsRecord { + compression_info: None, + owner, + cached: 0, + counter: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_cached() { + let owner = Pubkey::new_unique(); + + let record1 = SingleCompressAsRecord { + compression_info: None, + owner, + cached: 1, + counter: 0, + }; + + let record2 = SingleCompressAsRecord { + compression_info: None, + owner, + cached: 2, + counter: 0, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different cached value should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = SingleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + cached: 100, + counter: 100, + }; + + let record2 = SingleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + cached: 100, + counter: 100, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + let packed = PackedSingleCompressAsRecord { + compression_info: None, + owner: 0, + cached: 42, + counter: 100, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.cached, 42u64); + assert_eq!(packed.counter, 100u64); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = SingleCompressAsRecord { + compression_info: None, + owner, + cached: 50, + counter: 100, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.cached, 50); + assert_eq!(packed.counter, 100); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = SingleCompressAsRecord { + compression_info: None, + owner, + cached: 1, + counter: 1, + }; + + let record2 = SingleCompressAsRecord { + compression_info: None, + owner, + cached: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = SingleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + cached: 1, + counter: 1, + }; + + let record2 = SingleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + cached: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record_with_info = SingleCompressAsRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + cached: 100, + counter: 100, + }; + + let record_without_info = SingleCompressAsRecord { + compression_info: None, + owner: Pubkey::new_unique(), + cached: 200, + counter: 200, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record_with_info.pack(&mut packed_accounts); + let packed2 = record_without_info.pack(&mut packed_accounts); + + assert!( + packed1.compression_info.is_none(), + "pack should set compression_info to None" + ); + assert!( + packed2.compression_info.is_none(), + "pack should set compression_info to None" + ); +} + +#[test] +fn test_pack_stores_pubkeys_in_packed_accounts() { + let owner1 = Pubkey::new_unique(); + let owner2 = Pubkey::new_unique(); + + let record1 = SingleCompressAsRecord { + compression_info: None, + owner: owner1, + cached: 1, + counter: 1, + }; + + let record2 = SingleCompressAsRecord { + compression_info: None, + owner: owner2, + cached: 2, + counter: 2, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 pubkeys stored"); + assert_eq!( + stored_pubkeys[packed1.owner as usize], owner1, + "first pubkey should match" + ); + assert_eq!( + stored_pubkeys[packed2.owner as usize], owner2, + "second pubkey should match" + ); +} + +#[test] +fn test_pack_index_assignment_order() { + let mut packed_accounts = PackedAccounts::default(); + + let owners: Vec = (0..5).map(|_| Pubkey::new_unique()).collect(); + let mut indices = Vec::new(); + + for owner in &owners { + let record = SingleCompressAsRecord { + compression_info: None, + owner: *owner, + cached: 0, + counter: 0, + }; + let packed = record.pack(&mut packed_accounts); + indices.push(packed.owner); + } + + assert_eq!(indices, vec![0, 1, 2, 3, 4], "indices should be sequential"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_all_composition_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_all_composition_test.rs new file mode 100644 index 0000000000..cbc11bfc64 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_all_composition_test.rs @@ -0,0 +1,556 @@ +//! D4 Tests: AllCompositionRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `AllCompositionRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedAllCompositionRecord +//! +//! AllCompositionRecord has 3 Pubkey fields + 1 Option field and uses +//! #[compress_as(cached_time = 0, end_time = None)] to override field values. +//! This tests full Pack/Unpack behavior with compress_as attribute overrides. + +use csdk_anchor_full_derived_test::{AllCompositionRecord, PackedAllCompositionRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for AllCompositionRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(Pubkey::new_unique()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: Some(300), + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(Pubkey::new_unique()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: Some(300), + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(AllCompositionRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests with Attribute Overrides +// ============================================================================= + +#[test] +fn test_compress_as_overrides_cached_time() { + // #[compress_as(cached_time = 0, ...)] should set cached_time to 0 + let record = AllCompositionRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(Pubkey::new_unique()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 999, // This should be overridden to 0 + end_time: Some(300), + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + let compressed = record.compress_as(); + + // cached_time should be 0 due to #[compress_as(cached_time = 0)] + assert_eq!( + compressed.cached_time, 0, + "compress_as should override cached_time to 0" + ); +} + +#[test] +fn test_compress_as_overrides_end_time() { + // #[compress_as(..., end_time = None)] should set end_time to None + let record = AllCompositionRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(Pubkey::new_unique()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: Some(999), // This should be overridden to None + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + let compressed = record.compress_as(); + + // end_time should be None due to #[compress_as(..., end_time = None)] + assert!( + compressed.end_time.is_none(), + "compress_as should override end_time to None" + ); +} + +#[test] +fn test_compress_as_preserves_start_time() { + // start_time is NOT in #[compress_as(...)], so it should NOT be overridden + let start_time_value = 777u64; + + let record = AllCompositionRecord { + compression_info: Some(CompressionInfo::default()), + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(Pubkey::new_unique()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: start_time_value, + cached_time: 200, + end_time: Some(300), + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + let compressed = record.compress_as(); + + // start_time should be preserved because it's not in the #[compress_as(...)] + assert_eq!( + compressed.start_time, start_time_value, + "compress_as should NOT override start_time (not in compress_as attribute)" + ); +} + +#[test] +fn test_compress_as_preserves_non_override_fields() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record = AllCompositionRecord { + compression_info: Some(CompressionInfo::default()), + owner, + delegate, + authority, + close_authority: Some(Pubkey::new_unique()), + name: "custom_name".to_string(), + hash: [5u8; 32], + start_time: 500, + cached_time: 600, + end_time: Some(700), + counter_1: 11, + counter_2: 22, + counter_3: 33, + flag_1: false, + flag_2: true, + score: Some(99), + }; + + let compressed = record.compress_as(); + + // Fields not in compress_as should be preserved + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.delegate, delegate); + assert_eq!(compressed.authority, authority); + assert_eq!(compressed.counter_1, 11); + assert_eq!(compressed.counter_2, 22); + assert_eq!(compressed.counter_3, 33); + assert!(!compressed.flag_1); + assert!(compressed.flag_2); + assert_eq!(compressed.score, Some(99)); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = AllCompositionRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: None, + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: None, + }; + + let record2 = AllCompositionRecord { + compression_info: None, + owner: Pubkey::new_unique(), + delegate: record1.delegate, + authority: record1.authority, + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: None, + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: None, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_counter_3() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + + let record1 = AllCompositionRecord { + compression_info: None, + owner, + delegate, + authority, + close_authority: None, + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: None, + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: None, + }; + + let mut record2 = record1.clone(); + record2.counter_3 = 999; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter_3 should produce different hash" + ); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_pubkey_fields() { + // Verify PackedAllCompositionRecord has direct Pubkey fields as u8 + // Note: Option is NOT converted to Option - it stays as Option + let close_authority = Pubkey::new_unique(); + let packed = PackedAllCompositionRecord { + owner: 0, + delegate: 1, + authority: 2, + close_authority: Some(close_authority), + compression_info: None, + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 0, // overridden by compress_as + end_time: None, // overridden by compress_as + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.delegate, 1u8); + assert_eq!(packed.authority, 2u8); + assert_eq!(packed.close_authority, Some(close_authority)); +} + +#[test] +fn test_pack_converts_all_pubkeys_to_indices() { + let owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let close_authority = Pubkey::new_unique(); + + let record = AllCompositionRecord { + owner, + delegate, + authority, + close_authority: Some(close_authority), + compression_info: None, + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: Some(300), + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Direct Pubkey fields are converted to u8 indices + assert_eq!(packed.owner, 0u8); // First pubkey + assert_eq!(packed.delegate, 1u8); // Second pubkey + assert_eq!(packed.authority, 2u8); // Third pubkey + // Option is NOT converted to Option - it stays as Option + assert_eq!(packed.close_authority, Some(close_authority)); +} + +#[test] +fn test_pack_does_not_apply_compress_as_overrides() { + // Note: Pack does NOT apply compress_as overrides. Those are only applied + // by the CompressAs trait's compress_as() method. If you need overrides + // applied, call compress_as() first, then pack() the result. + let close_authority = Pubkey::new_unique(); + let record = AllCompositionRecord { + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(close_authority), + compression_info: Some(CompressionInfo::default()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 999, + end_time: Some(999), + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // Pack preserves field values - compress_as overrides are NOT applied + assert_eq!(packed.cached_time, 999, "pack preserves cached_time value"); + assert_eq!(packed.end_time, Some(999), "pack preserves end_time value"); + // Option stays as Option + assert_eq!(packed.close_authority, Some(close_authority)); +} + +#[test] +fn test_compress_as_then_pack_applies_overrides() { + // The correct way to pack with compress_as overrides: + // call compress_as() first, then pack() the result + let close_authority = Pubkey::new_unique(); + let record = AllCompositionRecord { + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(close_authority), + compression_info: Some(CompressionInfo::default()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 999, // Should become 0 after compress_as + end_time: Some(999), // Should become None after compress_as + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + // Chain compress_as() then pack() + let compressed = record.compress_as(); + let mut packed_accounts = PackedAccounts::default(); + let packed = compressed.pack(&mut packed_accounts); + + // compress_as overrides ARE applied when chained + assert_eq!( + packed.cached_time, 0, + "compress_as().pack() applies cached_time = 0 override" + ); + assert!( + packed.end_time.is_none(), + "compress_as().pack() applies end_time = None override" + ); + // Non-overridden fields preserved + assert_eq!(packed.start_time, 100); + assert_eq!(packed.counter_1, 1); +} + +#[test] +fn test_pack_preserves_start_time_without_override() { + let start_time_value = 555u64; + + let record = AllCompositionRecord { + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: None, + compression_info: None, + name: "test".to_string(), + hash: [0u8; 32], + start_time: start_time_value, + cached_time: 200, + end_time: None, + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: None, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + assert_eq!( + packed.start_time, start_time_value, + "pack should preserve start_time (not in compress_as override)" + ); +} + +#[test] +fn test_pack_reuses_duplicate_pubkeys_for_direct_fields() { + // Test that same Pubkey used in multiple direct Pubkey fields gets same index + let shared_pubkey = Pubkey::new_unique(); + + let record1 = AllCompositionRecord { + owner: shared_pubkey, + delegate: shared_pubkey, // Same as owner + authority: Pubkey::new_unique(), + close_authority: Some(shared_pubkey), // Option is NOT packed + compression_info: None, + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: None, + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: None, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record1.pack(&mut packed_accounts); + + // owner and delegate are the same pubkey, should get the same index + assert_eq!( + packed.owner, packed.delegate, + "same pubkey should get same index" + ); + + // Option is NOT converted to Option - it stays as Option + assert_eq!(packed.close_authority, Some(shared_pubkey)); + + // Only 2 unique pubkeys stored (shared_pubkey and authority) + let stored_pubkeys = packed_accounts.packed_pubkeys(); + assert_eq!(stored_pubkeys.len(), 2, "should have 2 unique pubkeys"); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let record = AllCompositionRecord { + owner: Pubkey::new_unique(), + delegate: Pubkey::new_unique(), + authority: Pubkey::new_unique(), + close_authority: Some(Pubkey::new_unique()), + compression_info: Some(CompressionInfo::default()), + name: "test".to_string(), + hash: [0u8; 32], + start_time: 100, + cached_time: 200, + end_time: Some(300), + counter_1: 1, + counter_2: 2, + counter_3: 3, + flag_1: true, + flag_2: false, + score: Some(50), + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + assert!( + packed.compression_info.is_none(), + "pack should set compression_info to None" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_info_last_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_info_last_test.rs new file mode 100644 index 0000000000..a2a023e49d --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_info_last_test.rs @@ -0,0 +1,320 @@ +//! D4 Tests: InfoLastRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `InfoLastRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! - CompressiblePack -> Pack + Unpack + PackedInfoLastRecord +//! +//! InfoLastRecord has 1 Pubkey field (owner) and demonstrates that +//! compression_info can be placed in non-first position (ordering test). + +use csdk_anchor_full_derived_test::{InfoLastRecord, PackedInfoLastRecord}; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + compressible::{CompressAs, CompressionInfo, Pack}, + instruction::PackedAccounts, +}; +use solana_pubkey::Pubkey; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for InfoLastRecord { + fn with_compression_info() -> Self { + Self { + owner: Pubkey::new_unique(), + counter: 0, + flag: false, + compression_info: Some(CompressionInfo::default()), + } + } + + fn without_compression_info() -> Self { + Self { + owner: Pubkey::new_unique(), + counter: 0, + flag: false, + compression_info: None, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(InfoLastRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_other_fields() { + let owner = Pubkey::new_unique(); + let counter = 999u64; + let flag = true; + + let record = InfoLastRecord { + owner, + counter, + flag, + compression_info: Some(CompressionInfo::default()), + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, counter); + assert_eq!(compressed.flag, flag); +} + +#[test] +fn test_compress_as_preserves_all_field_types() { + let owner = Pubkey::new_unique(); + + let record = InfoLastRecord { + owner, + counter: 42, + flag: true, + compression_info: Some(CompressionInfo::default()), + }; + + let compressed = record.compress_as(); + + // Verify all fields are preserved despite compression_info being last + assert_eq!(compressed.owner, owner); + assert_eq!(compressed.counter, 42); + assert!(compressed.flag); + assert!(compressed.compression_info.is_none()); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_counter() { + let owner = Pubkey::new_unique(); + + let record1 = InfoLastRecord { + owner, + counter: 1, + flag: false, + compression_info: None, + }; + + let record2 = InfoLastRecord { + owner, + counter: 2, + flag: false, + compression_info: None, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different counter should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_owner() { + let record1 = InfoLastRecord { + owner: Pubkey::new_unique(), + counter: 100, + flag: false, + compression_info: None, + }; + + let record2 = InfoLastRecord { + owner: Pubkey::new_unique(), + counter: 100, + flag: false, + compression_info: None, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different owner should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_flag() { + let owner = Pubkey::new_unique(); + + let record1 = InfoLastRecord { + owner, + counter: 50, + flag: true, + compression_info: None, + }; + + let record2 = InfoLastRecord { + owner, + counter: 50, + flag: false, + compression_info: None, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!(hash1, hash2, "different flag should produce different hash"); +} + +// ============================================================================= +// Pack/Unpack Tests (struct-specific, cannot be generic) +// ============================================================================= + +#[test] +fn test_packed_struct_has_u8_owner() { + // Verify PackedInfoLastRecord has the expected structure + // The Packed struct uses the same field name but changes type to u8 + let packed = PackedInfoLastRecord { + owner: 0, + counter: 42, + flag: true, + compression_info: None, + }; + + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.counter, 42u64); + assert!(packed.flag); +} + +#[test] +fn test_pack_converts_pubkey_to_index() { + let owner = Pubkey::new_unique(); + let record = InfoLastRecord { + owner, + counter: 100, + flag: false, + compression_info: None, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (0 for first pubkey) + assert_eq!(packed.owner, 0u8); + assert_eq!(packed.counter, 100); + assert!(!packed.flag); + + let mut packed_accounts = PackedAccounts::default(); + packed_accounts.insert_or_get(Pubkey::new_unique()); + let packed = record.pack(&mut packed_accounts); + + // The owner should have been added to packed_accounts + // and packed.owner should be the index (1 for second pubkey) + assert_eq!(packed.owner, 1u8); + assert_eq!(packed.counter, 100); +} + +#[test] +fn test_pack_reuses_same_pubkey_index() { + let owner = Pubkey::new_unique(); + + let record1 = InfoLastRecord { + owner, + counter: 1, + flag: true, + compression_info: None, + }; + + let record2 = InfoLastRecord { + owner, + counter: 2, + flag: false, + compression_info: None, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Same pubkey should get same index + assert_eq!( + packed1.owner, packed2.owner, + "same pubkey should produce same index" + ); +} + +#[test] +fn test_pack_preserves_counter_and_flag() { + let owner = Pubkey::new_unique(); + let counter = 777u64; + let flag = true; + + let record = InfoLastRecord { + owner, + counter, + flag, + compression_info: None, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record.pack(&mut packed_accounts); + + // counter and flag should be preserved + assert_eq!(packed.counter, counter); + assert_eq!(packed.flag, flag); +} + +#[test] +fn test_pack_sets_compression_info_to_none() { + let owner = Pubkey::new_unique(); + + let record_with_info = InfoLastRecord { + owner, + counter: 100, + flag: true, + compression_info: Some(CompressionInfo::default()), + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed = record_with_info.pack(&mut packed_accounts); + + assert!( + packed.compression_info.is_none(), + "pack should set compression_info to None (even if input has Some)" + ); +} + +#[test] +fn test_pack_different_pubkeys_get_different_indices() { + let record1 = InfoLastRecord { + owner: Pubkey::new_unique(), + counter: 1, + flag: true, + compression_info: None, + }; + + let record2 = InfoLastRecord { + owner: Pubkey::new_unique(), + counter: 2, + flag: false, + compression_info: None, + }; + + let mut packed_accounts = PackedAccounts::default(); + let packed1 = record1.pack(&mut packed_accounts); + let packed2 = record2.pack(&mut packed_accounts); + + // Different pubkeys should get different indices + assert_ne!( + packed1.owner, packed2.owner, + "different pubkeys should produce different indices" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_large_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_large_test.rs new file mode 100644 index 0000000000..144b2c8bfa --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_large_test.rs @@ -0,0 +1,224 @@ +//! D4 Tests: LargeRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `LargeRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! +//! LargeRecord has NO Pubkey fields and 12 u64 fields (13 total including compression_info). +//! This exercises the SHA256 hash mode for large structs. +//! Pack/Unpack traits are NOT generated because there are no Pubkey fields. + +use csdk_anchor_full_derived_test::LargeRecord; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::compressible::{CompressAs, CompressionInfo}; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for LargeRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + field_01: 1, + field_02: 2, + field_03: 3, + field_04: 4, + field_05: 5, + field_06: 6, + field_07: 7, + field_08: 8, + field_09: 9, + field_10: 10, + field_11: 11, + field_12: 12, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + field_01: 1, + field_02: 2, + field_03: 3, + field_04: 4, + field_05: 5, + field_06: 6, + field_07: 7, + field_08: 8, + field_09: 9, + field_10: 10, + field_11: 11, + field_12: 12, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(LargeRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_all_fields() { + let record = LargeRecord { + compression_info: Some(CompressionInfo::default()), + field_01: 100, + field_02: 200, + field_03: 300, + field_04: 400, + field_05: 500, + field_06: 600, + field_07: 700, + field_08: 800, + field_09: 900, + field_10: 1000, + field_11: 1100, + field_12: 1200, + }; + + let compressed = record.compress_as(); + + // Verify all fields are preserved + assert_eq!(compressed.field_01, 100); + assert_eq!(compressed.field_02, 200); + assert_eq!(compressed.field_03, 300); + assert_eq!(compressed.field_04, 400); + assert_eq!(compressed.field_05, 500); + assert_eq!(compressed.field_06, 600); + assert_eq!(compressed.field_07, 700); + assert_eq!(compressed.field_08, 800); + assert_eq!(compressed.field_09, 900); + assert_eq!(compressed.field_10, 1000); + assert_eq!(compressed.field_11, 1100); + assert_eq!(compressed.field_12, 1200); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let record = LargeRecord { + compression_info: None, + field_01: 1, + field_02: 2, + field_03: 3, + field_04: 4, + field_05: 5, + field_06: 6, + field_07: 7, + field_08: 8, + field_09: 9, + field_10: 10, + field_11: 11, + field_12: 12, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve all fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.field_01, 1); + assert_eq!(compressed.field_12, 12); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests (SHA256 mode) +// ============================================================================= + +#[test] +fn test_hash_produces_32_bytes_for_large_struct() { + let record = LargeRecord::without_compression_info(); + let hash = record.hash::().expect("hash should succeed"); + assert_eq!(hash.len(), 32, "SHA256 hash should produce 32 bytes"); +} + +#[test] +fn test_hash_differs_for_different_field_01() { + let mut record1 = LargeRecord::without_compression_info(); + let mut record2 = LargeRecord::without_compression_info(); + + record1.field_01 = 100; + record2.field_01 = 200; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different field_01 should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_field_06() { + let mut record1 = LargeRecord::without_compression_info(); + let mut record2 = LargeRecord::without_compression_info(); + + record1.field_06 = 600; + record2.field_06 = 700; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different field_06 should produce different hash" + ); +} + +#[test] +fn test_hash_differs_for_different_field_12() { + let mut record1 = LargeRecord::without_compression_info(); + let mut record2 = LargeRecord::without_compression_info(); + + record1.field_12 = 1200; + record2.field_12 = 1300; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different field_12 should produce different hash" + ); +} + +#[test] +fn test_hash_same_for_same_large_struct() { + let record1 = LargeRecord::without_compression_info(); + let record2 = record1.clone(); + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_eq!( + hash1, hash2, + "identical large records should produce same hash" + ); +} + +#[test] +fn test_hash_includes_all_fields_by_changing_middle_field() { + let mut record1 = LargeRecord::without_compression_info(); + let mut record2 = LargeRecord::without_compression_info(); + + // Change a field in the middle + record1.field_06 = 600; + record2.field_06 = 999; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "changing middle field should change hash (all fields included)" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_minimal_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_minimal_test.rs new file mode 100644 index 0000000000..e07cb324fd --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/d4_minimal_test.rs @@ -0,0 +1,119 @@ +//! D4 Tests: MinimalRecord trait derive tests +//! +//! Tests each trait derived by `RentFreeAccount` macro for `MinimalRecord`: +//! - LightHasherSha -> DataHasher + ToByteArray +//! - LightDiscriminator -> LIGHT_DISCRIMINATOR constant +//! - Compressible -> HasCompressionInfo + CompressAs + Size + CompressedInitSpace +//! +//! MinimalRecord has NO Pubkey fields, so Pack/Unpack traits are NOT generated. + +use csdk_anchor_full_derived_test::MinimalRecord; +use light_hasher::{DataHasher, Sha256}; +use light_sdk::compressible::{CompressAs, CompressionInfo}; + +use super::shared::CompressibleTestFactory; +use crate::generate_trait_tests; + +// ============================================================================= +// Factory Implementation +// ============================================================================= + +impl CompressibleTestFactory for MinimalRecord { + fn with_compression_info() -> Self { + Self { + compression_info: Some(CompressionInfo::default()), + value: 42u64, + } + } + + fn without_compression_info() -> Self { + Self { + compression_info: None, + value: 42u64, + } + } +} + +// ============================================================================= +// Generate all generic trait tests via macro +// ============================================================================= + +generate_trait_tests!(MinimalRecord); + +// ============================================================================= +// Struct-Specific CompressAs Tests +// ============================================================================= + +#[test] +fn test_compress_as_preserves_value() { + let value = 999u64; + + let record = MinimalRecord { + compression_info: Some(CompressionInfo::default()), + value, + }; + + let compressed = record.compress_as(); + assert_eq!(compressed.value, value); +} + +#[test] +fn test_compress_as_when_compression_info_already_none() { + let value = 123u64; + + let record = MinimalRecord { + compression_info: None, + value, + }; + + let compressed = record.compress_as(); + + // Should still work and preserve fields + assert!(compressed.compression_info.is_none()); + assert_eq!(compressed.value, value); +} + +// ============================================================================= +// Struct-Specific DataHasher Tests +// ============================================================================= + +#[test] +fn test_hash_differs_for_different_value() { + let record1 = MinimalRecord { + compression_info: None, + value: 1, + }; + + let record2 = MinimalRecord { + compression_info: None, + value: 2, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "different value should produce different hash" + ); +} + +#[test] +fn test_hash_same_for_same_value() { + let value = 100u64; + + let record1 = MinimalRecord { + compression_info: None, + value, + }; + + let record2 = MinimalRecord { + compression_info: None, + value, + }; + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_eq!(hash1, hash2, "same value should produce same hash"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/shared.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/shared.rs new file mode 100644 index 0000000000..3a5cdf6755 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/account_macros/shared.rs @@ -0,0 +1,409 @@ +//! Shared generic test helpers for RentFreeAccount-derived traits. +//! +//! These functions test trait implementations generically and can be reused +//! across different account struct types. + +use std::borrow::Cow; + +use light_hasher::{DataHasher, Sha256}; +use light_sdk::{ + account::Size, + compressible::{CompressAs, CompressedInitSpace, CompressionInfo, HasCompressionInfo}, + LightDiscriminator, +}; + +// ============================================================================= +// Test Factory Trait +// ============================================================================= + +/// Trait for creating test instances of compressible account structs. +/// +/// Implement this trait for each account struct to enable generic testing. +pub trait CompressibleTestFactory: Sized { + /// Create an instance with `compression_info = Some(CompressionInfo::default())` + fn with_compression_info() -> Self; + + /// Create an instance with `compression_info = None` + fn without_compression_info() -> Self; +} + +// ============================================================================= +// LightDiscriminator Tests (4 tests) +// ============================================================================= + +/// Verifies LIGHT_DISCRIMINATOR is exactly 8 bytes. +pub fn assert_discriminator_is_8_bytes() { + let discriminator = T::LIGHT_DISCRIMINATOR; + assert_eq!( + discriminator.len(), + 8, + "LIGHT_DISCRIMINATOR should be 8 bytes" + ); +} + +/// Verifies LIGHT_DISCRIMINATOR is not all zeros. +pub fn assert_discriminator_is_non_zero() { + let discriminator = T::LIGHT_DISCRIMINATOR; + let all_zero = discriminator.iter().all(|&b| b == 0); + assert!(!all_zero, "LIGHT_DISCRIMINATOR should not be all zeros"); +} + +/// Verifies discriminator() method returns the same value as LIGHT_DISCRIMINATOR constant. +pub fn assert_discriminator_method_matches_constant() { + let from_method = T::discriminator(); + let from_constant = T::LIGHT_DISCRIMINATOR; + assert_eq!( + from_method, from_constant, + "discriminator() should return LIGHT_DISCRIMINATOR" + ); +} + +/// Verifies LIGHT_DISCRIMINATOR_SLICE matches the LIGHT_DISCRIMINATOR array. +pub fn assert_discriminator_slice_matches_array() { + let array = T::LIGHT_DISCRIMINATOR; + let slice = T::LIGHT_DISCRIMINATOR_SLICE; + + assert_eq!( + slice, &array, + "LIGHT_DISCRIMINATOR_SLICE should match LIGHT_DISCRIMINATOR array" + ); + assert_eq!(slice.len(), 8); +} + +// ============================================================================= +// HasCompressionInfo Tests (6 tests) +// ============================================================================= + +/// Verifies compression_info() returns a valid reference when Some. +pub fn assert_compression_info_returns_reference< + T: HasCompressionInfo + CompressibleTestFactory, +>() { + let record = T::with_compression_info(); + let info = record.compression_info(); + // Just verify we can access it - the default values + assert_eq!(info.config_version, 0); + assert_eq!(info.lamports_per_write, 0); +} + +/// Verifies compression_info_mut() allows modification. +pub fn assert_compression_info_mut_allows_modification< + T: HasCompressionInfo + CompressibleTestFactory, +>() { + let mut record = T::with_compression_info(); + + { + let info = record.compression_info_mut(); + info.config_version = 99; + info.lamports_per_write = 1000; + } + + assert_eq!(record.compression_info().config_version, 99); + assert_eq!(record.compression_info().lamports_per_write, 1000); +} + +/// Verifies compression_info_mut_opt() returns a mutable reference to the Option. +pub fn assert_compression_info_mut_opt_works() { + let mut record = T::with_compression_info(); + + // Should be able to access and modify the Option itself + let opt = record.compression_info_mut_opt(); + assert!(opt.is_some()); + + // Set to None via the mutable reference + *opt = None; + + // Verify it changed + let opt2 = record.compression_info_mut_opt(); + assert!(opt2.is_none()); + + // Set back to Some + *opt2 = Some(CompressionInfo::default()); + assert!(record.compression_info_mut_opt().is_some()); +} + +/// Verifies set_compression_info_none() sets the field to None. +pub fn assert_set_compression_info_none_works() { + let mut record = T::with_compression_info(); + + // Verify it starts as Some + assert!(record.compression_info_mut_opt().is_some()); + + record.set_compression_info_none(); + + // Verify it's now None + assert!(record.compression_info_mut_opt().is_none()); +} + +/// Verifies compression_info() panics when compression_info is None. +/// Call this from a test marked with `#[should_panic]`. +pub fn assert_compression_info_panics_when_none() { + let record = T::without_compression_info(); + // This should panic since compression_info is None + let _ = record.compression_info(); +} + +/// Verifies compression_info_mut() panics when compression_info is None. +/// Call this from a test marked with `#[should_panic]`. +pub fn assert_compression_info_mut_panics_when_none< + T: HasCompressionInfo + CompressibleTestFactory, +>() { + let mut record = T::without_compression_info(); + // This should panic since compression_info is None + let _ = record.compression_info_mut(); +} + +// ============================================================================= +// CompressAs Tests (2 tests) +// ============================================================================= + +/// Verifies compress_as() sets compression_info to None. +pub fn assert_compress_as_sets_compression_info_to_none< + T: CompressAs + HasCompressionInfo + CompressibleTestFactory + Clone, +>() { + let record = T::with_compression_info(); + let compressed = record.compress_as(); + + // Get the inner value - compress_as should return Owned when it modifies + let mut inner = compressed.into_owned(); + assert!( + inner.compression_info_mut_opt().is_none(), + "compress_as should set compression_info to None" + ); +} + +/// Verifies compress_as() returns Cow::Owned when compression_info is Some. +pub fn assert_compress_as_returns_owned_cow< + T: CompressAs + HasCompressionInfo + CompressibleTestFactory + Clone, +>() { + let record = T::with_compression_info(); + let compressed = record.compress_as(); + + assert!( + matches!(compressed, Cow::Owned(_)), + "compress_as should return Cow::Owned when compression_info is Some" + ); +} + +// ============================================================================= +// Size Tests (2 tests) +// ============================================================================= + +/// Verifies size() returns a positive value. +pub fn assert_size_returns_positive() { + let record = T::with_compression_info(); + let size = record.size(); + assert!(size > 0, "size should be positive"); +} + +/// Verifies size() returns the same value when called multiple times on the same instance. +pub fn assert_size_is_deterministic() { + let record = T::with_compression_info(); + let record_clone = record.clone(); + + let size1 = record.size(); + let size2 = record_clone.size(); + + assert_eq!(size1, size2, "size should be deterministic for same data"); +} + +// ============================================================================= +// CompressedInitSpace Tests (1 test) +// ============================================================================= + +/// Verifies COMPRESSED_INIT_SPACE is at least as large as the discriminator. +pub fn assert_compressed_init_space_includes_discriminator< + T: CompressedInitSpace + LightDiscriminator, +>() { + let compressed_space = T::COMPRESSED_INIT_SPACE; + let discriminator_len = T::LIGHT_DISCRIMINATOR.len(); + + assert!( + compressed_space >= discriminator_len, + "COMPRESSED_INIT_SPACE ({}) should be >= discriminator length ({})", + compressed_space, + discriminator_len + ); +} + +// ============================================================================= +// DataHasher Tests (3 tests) +// ============================================================================= + +/// Verifies hash() produces a 32-byte result. +pub fn assert_hash_produces_32_bytes() { + let record = T::without_compression_info(); + let hash = record.hash::().expect("hash should succeed"); + assert_eq!(hash.len(), 32, "hash should produce 32-byte result"); +} + +/// Verifies hash() is deterministic (same input = same hash). +pub fn assert_hash_is_deterministic() { + let record1 = T::without_compression_info(); + let record2 = record1.clone(); + + let hash1 = record1.hash::().expect("hash should succeed"); + let hash2 = record2.hash::().expect("hash should succeed"); + + assert_eq!(hash1, hash2, "same input should produce same hash"); +} + +/// Verifies compression_info IS included in the hash (LightHasherSha behavior). +pub fn assert_hash_includes_compression_info() { + let record_with_info = T::with_compression_info(); + let record_without_info = T::without_compression_info(); + + let hash1 = record_with_info + .hash::() + .expect("hash should succeed"); + let hash2 = record_without_info + .hash::() + .expect("hash should succeed"); + + assert_ne!( + hash1, hash2, + "compression_info SHOULD affect hash - LightHasherSha hashes entire struct" + ); +} + +// ============================================================================= +// Macro for generating all trait tests +// ============================================================================= + +/// Generates all generic trait tests for a given type. +/// +/// Usage: +/// ```ignore +/// generate_trait_tests!(SinglePubkeyRecord); +/// ``` +#[macro_export] +macro_rules! generate_trait_tests { + ($type:ty) => { + mod discriminator_tests { + use $crate::shared::*; + + use super::*; + + #[test] + fn test_discriminator_is_8_bytes() { + assert_discriminator_is_8_bytes::<$type>(); + } + + #[test] + fn test_discriminator_is_non_zero() { + assert_discriminator_is_non_zero::<$type>(); + } + + #[test] + fn test_discriminator_method_matches_constant() { + assert_discriminator_method_matches_constant::<$type>(); + } + + #[test] + fn test_discriminator_slice_matches_array() { + assert_discriminator_slice_matches_array::<$type>(); + } + } + + mod has_compression_info_tests { + use $crate::shared::*; + + use super::*; + + #[test] + fn test_compression_info_returns_reference() { + assert_compression_info_returns_reference::<$type>(); + } + + #[test] + fn test_compression_info_mut_allows_modification() { + assert_compression_info_mut_allows_modification::<$type>(); + } + + #[test] + fn test_compression_info_mut_opt_works() { + assert_compression_info_mut_opt_works::<$type>(); + } + + #[test] + fn test_set_compression_info_none_works() { + assert_set_compression_info_none_works::<$type>(); + } + + #[test] + #[should_panic] + fn test_compression_info_panics_when_none() { + assert_compression_info_panics_when_none::<$type>(); + } + + #[test] + #[should_panic] + fn test_compression_info_mut_panics_when_none() { + assert_compression_info_mut_panics_when_none::<$type>(); + } + } + + mod compress_as_tests { + use $crate::shared::*; + + use super::*; + + #[test] + fn test_compress_as_sets_compression_info_to_none() { + assert_compress_as_sets_compression_info_to_none::<$type>(); + } + + #[test] + fn test_compress_as_returns_owned_cow() { + assert_compress_as_returns_owned_cow::<$type>(); + } + } + + mod size_tests { + use $crate::shared::*; + + use super::*; + + #[test] + fn test_size_returns_positive() { + assert_size_returns_positive::<$type>(); + } + + #[test] + fn test_size_is_deterministic() { + assert_size_is_deterministic::<$type>(); + } + } + + mod compressed_init_space_tests { + use $crate::shared::*; + + use super::*; + + #[test] + fn test_compressed_init_space_includes_discriminator() { + assert_compressed_init_space_includes_discriminator::<$type>(); + } + } + + mod data_hasher_tests { + use $crate::shared::*; + + use super::*; + + #[test] + fn test_hash_produces_32_bytes() { + assert_hash_produces_32_bytes::<$type>(); + } + + #[test] + fn test_hash_is_deterministic() { + assert_hash_is_deterministic::<$type>(); + } + + #[test] + fn test_hash_includes_compression_info() { + assert_hash_includes_compression_info::<$type>(); + } + } + }; +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index 013ecb25ef..a257496817 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -10,23 +10,30 @@ mod shared; use anchor_lang::{InstructionData, ToAccountMetas}; -use csdk_anchor_full_derived_test::amm_test::{ - InitializeParams, AUTH_SEED, OBSERVATION_SEED, POOL_LP_MINT_SIGNER_SEED, POOL_SEED, - POOL_VAULT_SEED, +use csdk_anchor_full_derived_test::{ + amm_test::{ + InitializeParams, AUTH_SEED, OBSERVATION_SEED, POOL_LP_MINT_SIGNER_SEED, POOL_SEED, + POOL_VAULT_SEED, + }, + csdk_anchor_full_derived_test::{ObservationStateSeeds, PoolStateSeeds, TokenAccountVariant}, }; +use light_compressed_account::instruction_data::compressed_proof::ValidityProof; use light_compressible::rent::SLOTS_PER_EPOCH; use light_compressible_client::{ - get_create_accounts_proof, CreateAccountsProofInput, InitializeRentFreeConfig, + create_load_accounts_instructions, get_create_accounts_proof, AccountInterface, + AccountInterfaceExt, CreateAccountsProofInput, InitializeRentFreeConfig, + RentFreeDecompressAccount, }; use light_macros::pubkey; use light_program_test::{ program_test::{setup_mock_program_data, LightProgramTest, TestRpc}, Indexer, ProgramTestConfig, Rpc, }; -use light_token_interface::state::Token; +use light_token_interface::{instructions::mint_action::MintInstructionData, state::Token}; use light_token_sdk::token::{ - find_mint_address, get_associated_token_address_and_bump, COMPRESSIBLE_CONFIG_V1, - LIGHT_TOKEN_CPI_AUTHORITY, LIGHT_TOKEN_PROGRAM_ID, RENT_SPONSOR as LIGHT_TOKEN_RENT_SPONSOR, + find_mint_address, get_associated_token_address_and_bump, CreateAssociatedTokenAccount, + Decompress, DecompressMint, MintWithContext, COMPRESSIBLE_CONFIG_V1, LIGHT_TOKEN_CPI_AUTHORITY, + LIGHT_TOKEN_PROGRAM_ID, RENT_SPONSOR as LIGHT_TOKEN_RENT_SPONSOR, }; use solana_instruction::Instruction; use solana_keypair::Keypair; @@ -601,29 +608,335 @@ async fn test_amm_full_lifecycle() { // ========================================================================== // PHASE 9: Decompress accounts // ========================================================================== - // Note: Decompression requires the seed structs generated by #[rentfree_program] - // macro. We would need to import them like: - // use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ - // PoolStateSeeds, ObservationStateSeeds, TokenAccountVariant - // }; - // - // For now, we verify that compression worked. Full decompression test - // requires the macro-generated types to be available. - - println!("\nDecompression phase would use:"); - println!(" - get_account_info_interface for pool_state and observation_state"); - println!(" - get_token_account_interface for vaults"); - println!(" - get_ata_interface for creator_lp_token"); - println!(" - get_mint_interface for lp_mint"); - println!(" - create_load_accounts_instructions to generate decompression ixs"); - - // TODO: Add full decompression test once seed structs are available - // This would follow the pattern in basic_test.rs Phase 3 + println!("\nPhase 9: Decompressing all accounts..."); + + // Fetch unified interfaces (hot/cold transparent) for PDAs + let pool_interface = ctx + .rpc + .get_account_info_interface(&pdas.pool_state, &ctx.program_id) + .await + .expect("failed to get pool_state"); + assert!(pool_interface.is_cold, "pool_state should be cold"); + + let observation_interface = ctx + .rpc + .get_account_info_interface(&pdas.observation_state, &ctx.program_id) + .await + .expect("failed to get observation_state"); + assert!( + observation_interface.is_cold, + "observation_state should be cold" + ); + + // Fetch token account interfaces for vaults + let vault_0_interface = ctx + .rpc + .get_token_account_interface(&pdas.token_0_vault) + .await + .expect("failed to get token_0_vault"); + assert!(vault_0_interface.is_cold, "token_0_vault should be cold"); + + let vault_1_interface = ctx + .rpc + .get_token_account_interface(&pdas.token_1_vault) + .await + .expect("failed to get token_1_vault"); + assert!(vault_1_interface.is_cold, "token_1_vault should be cold"); + + // Fetch ATA interface for creator LP token + let creator_lp_interface = ctx + .rpc + .get_ata_interface(&ctx.creator.pubkey(), &pdas.lp_mint) + .await + .expect("failed to get creator_lp_token"); + assert!( + creator_lp_interface.is_cold(), + "creator_lp_token should be cold" + ); + assert_eq!( + creator_lp_interface.amount(), + expected_balance_after_withdraw, + "Compressed LP token amount should match" + ); + + // Fetch mint interface for LP mint + let lp_mint_interface = ctx + .rpc + .get_mint_interface(&pdas.lp_mint_signer) + .await + .expect("failed to get lp_mint"); + assert!(lp_mint_interface.is_cold(), "lp_mint should be cold"); + + // Build RentFreeDecompressAccount list for program-owned accounts + let program_owned_accounts = vec![ + RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&pool_interface), + PoolStateSeeds { + amm_config: ctx.amm_config.pubkey(), + token_0_mint: ctx.token_0_mint, + token_1_mint: ctx.token_1_mint, + }, + ) + .expect("PoolState seed verification failed"), + RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&observation_interface), + ObservationStateSeeds { + pool_state: pdas.pool_state, + }, + ) + .expect("ObservationState seed verification failed"), + RentFreeDecompressAccount::from_ctoken( + AccountInterface::from(&vault_0_interface), + TokenAccountVariant::Token0Vault { + pool_state: pdas.pool_state, + token_0_mint: ctx.token_0_mint, + }, + ) + .expect("Token0Vault construction failed"), + RentFreeDecompressAccount::from_ctoken( + AccountInterface::from(&vault_1_interface), + TokenAccountVariant::Token1Vault { + pool_state: pdas.pool_state, + token_1_mint: ctx.token_1_mint, + }, + ) + .expect("Token1Vault construction failed"), + ]; + for account in program_owned_accounts { + // Create decompression instructions + let all_instructions = create_load_accounts_instructions( + &[account], + &[], //std::slice::from_ref(&creator_lp_interface.inner), TODO decompress directly from ctoken program + &[], // std::slice::from_ref(&lp_mint_interface), TODO decompress directly from ctoken program + ctx.program_id, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), // rent_sponsor + &ctx.rpc, + ) + .await + .expect("create_load_accounts_instructions should succeed"); + + println!( + " Generated {} decompression instructions", + all_instructions.len() + ); + + // Execute decompression + ctx.rpc + .create_and_send_transaction(&all_instructions, &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("Decompression should succeed"); + } + + // Decompress LP mint manually + if lp_mint_interface.is_cold() { + println!(" Decompressing LP mint..."); + let (compressed, mint_data) = lp_mint_interface + .compressed() + .expect("LP mint should have compressed data"); + + // Get validity proof for the mint + let proof_result = ctx + .rpc + .get_validity_proof(vec![compressed.hash], vec![], None) + .await + .expect("get_validity_proof should succeed") + .value; + + let account_info = &proof_result.accounts[0]; + let state_tree = account_info.tree_info.tree; + let input_queue = account_info.tree_info.queue; + let output_queue = account_info + .tree_info + .next_tree_info + .as_ref() + .map(|n| n.queue) + .unwrap_or(input_queue); + + let mint_instruction_data = MintInstructionData::try_from(mint_data.clone()) + .expect("MintInstructionData conversion should succeed"); + + let decompress_mint_ix = DecompressMint { + payer: ctx.payer.pubkey(), + authority: ctx.payer.pubkey(), + state_tree, + input_queue, + output_queue, + compressed_mint_with_context: MintWithContext { + leaf_index: account_info.leaf_index as u32, + prove_by_index: account_info.root_index.proof_by_index(), + root_index: account_info.root_index.root_index().unwrap_or_default(), + address: lp_mint_interface.compressed_address, + mint: Some(mint_instruction_data), + }, + proof: ValidityProof(proof_result.proof.into()), + rent_payment: 2, + write_top_up: 766, + } + .instruction() + .expect("DecompressMint instruction should succeed"); + + ctx.rpc + .create_and_send_transaction(&[decompress_mint_ix], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("LP mint decompression should succeed"); + } + + // Decompress creator LP token ATA manually + if creator_lp_interface.is_cold() { + println!(" Decompressing creator LP token ATA..."); + + // First create the ATA (idempotent) + let create_ata_ix = CreateAssociatedTokenAccount::new( + ctx.payer.pubkey(), + ctx.creator.pubkey(), + pdas.lp_mint, + ) + .idempotent() + .instruction() + .expect("CreateAssociatedTokenAccount instruction should succeed"); + + ctx.rpc + .create_and_send_transaction(&[create_ata_ix], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("Create ATA should succeed"); + + // Get the compressed token account data + let load_context = creator_lp_interface + .inner + .load_context + .as_ref() + .expect("ATA should have load_context"); + let compressed = &load_context.compressed; + + // Get validity proof + let proof_result = ctx + .rpc + .get_validity_proof(vec![compressed.account.hash], vec![], None) + .await + .expect("get_validity_proof should succeed") + .value; + + let account_info = &proof_result.accounts[0]; + + // Build TokenData from the compressed token account + use light_token_sdk::compat::TokenData; + let token_data = TokenData { + mint: compressed.token.mint, + owner: compressed.token.owner, + amount: compressed.token.amount, + delegate: compressed.token.delegate, + state: compressed.token.state, + tlv: compressed.token.tlv.clone(), + }; + + // Get discriminator from compressed account data + let discriminator = compressed + .account + .data + .as_ref() + .map(|d| d.discriminator) + .unwrap_or([0, 0, 0, 0, 0, 0, 0, 4]); // ShaFlat default + + // Build Decompress instruction + let decompress_ix = Decompress { + token_data, + discriminator, + merkle_tree: account_info.tree_info.tree, + queue: account_info.tree_info.queue, + leaf_index: account_info.leaf_index as u32, + root_index: account_info.root_index.root_index().unwrap_or_default(), + destination: creator_lp_interface.inner.pubkey, + payer: ctx.payer.pubkey(), + signer: ctx.creator.pubkey(), + validity_proof: ValidityProof(proof_result.proof.into()), + } + .instruction() + .expect("Decompress instruction should succeed"); + + ctx.rpc + .create_and_send_transaction( + &[decompress_ix], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.creator], + ) + .await + .expect("ATA decompression should succeed"); + } + + // ========================================================================== + // PHASE 10: Assert decompression success + // ========================================================================== + println!("\nPhase 10: Verifying decompression..."); + + // All accounts should be back on-chain + assert_onchain_exists(&mut ctx.rpc, &pdas.pool_state).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.observation_state).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.lp_mint).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.token_0_vault).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.token_1_vault).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.creator_lp_token).await; + + // Verify LP token balance preserved after decompression + let lp_token_after_decompression = parse_token( + &ctx.rpc + .get_account(pdas.creator_lp_token) + .await + .unwrap() + .unwrap() + .data, + ); + assert_eq!( + lp_token_after_decompression.amount, expected_balance_after_withdraw, + "LP token balance should be preserved after decompression" + ); + println!( + " LP balance after decompression: {} (expected: {})", + lp_token_after_decompression.amount, expected_balance_after_withdraw + ); + + // Verify compressed token accounts are consumed + let remaining_vault_0 = ctx + .rpc + .get_compressed_token_accounts_by_owner(&pdas.token_0_vault, None, None) + .await + .unwrap() + .value + .items; + assert!( + remaining_vault_0.is_empty(), + "Compressed token_0_vault should be consumed" + ); + + let remaining_vault_1 = ctx + .rpc + .get_compressed_token_accounts_by_owner(&pdas.token_1_vault, None, None) + .await + .unwrap() + .value + .items; + assert!( + remaining_vault_1.is_empty(), + "Compressed token_1_vault should be consumed" + ); + + let remaining_creator_lp = ctx + .rpc + .get_compressed_token_accounts_by_owner(&pdas.creator_lp_token, None, None) + .await + .unwrap() + .value + .items; + assert!( + remaining_creator_lp.is_empty(), + "Compressed creator_lp_token should be consumed" + ); println!("\nAMM full lifecycle test completed successfully!"); println!(" - Initialize: OK"); println!(" - Deposit: OK"); println!(" - Withdraw: OK"); println!(" - Compression: OK"); - println!(" - Decompression: TODO (requires seed struct types)"); + println!(" - Decompression: OK"); } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index 3f26e3e3ad..b5867c044c 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -25,7 +25,7 @@ const RENT_SPONSOR: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcA async fn test_create_pdas_and_mint_auto() { use csdk_anchor_full_derived_test::{ instruction_accounts::{LP_MINT_SIGNER_SEED, VAULT_SEED}, - FullAutoWithMintParams, + FullAutoWithMintParams, GameSession, }; use light_token_interface::state::Token; use light_token_sdk::token::{ @@ -245,6 +245,45 @@ async fn test_create_pdas_and_mint_auto() { assert_eq!(compressed_cmint.address.unwrap(), mint_compressed_address); assert!(compressed_cmint.data.as_ref().unwrap().data.is_empty()); + // Verify GameSession initial state before compression + // Fields with compress_as overrides should have their original values + let initial_game_session_data = rpc + .get_account(game_session_pda) + .await + .unwrap() + .expect("GameSession should exist after init"); + let initial_game_session: GameSession = + borsh::BorshDeserialize::deserialize(&mut &initial_game_session_data.data[8..]) + .expect("Failed to deserialize initial GameSession"); + + // Verify initial state: start_time should be hardcoded value (2) + assert_eq!( + initial_game_session.start_time, 2, + "Initial start_time should be 2 (hardcoded non-zero), got: {}", + initial_game_session.start_time + ); + assert_eq!( + initial_game_session.session_id, session_id, + "session_id should be preserved" + ); + assert_eq!( + initial_game_session.player, + payer.pubkey(), + "player should be payer" + ); + assert_eq!( + initial_game_session.game_type, "Auto Game With Mint", + "game_type should match" + ); + assert_eq!( + initial_game_session.end_time, None, + "end_time should be None" + ); + assert_eq!(initial_game_session.score, 0, "score should be 0"); + + // Store initial start_time for comparison after decompress + let initial_start_time = initial_game_session.start_time; + // PHASE 2: Warp to trigger auto-compression rpc.warp_slot_forward(SLOTS_PER_EPOCH * 30).await.unwrap(); @@ -383,4 +422,427 @@ async fn test_create_pdas_and_mint_auto() { .value .items; assert!(remaining_vault.is_empty()); + + // PHASE 4: Verify compress_as field overrides on GameSession + // After decompress, fields with #[compress_as(...)] should be reset to override values + let game_session_data = rpc + .get_account(game_session_pda) + .await + .unwrap() + .expect("GameSession account should exist"); + let game_session: GameSession = + borsh::BorshDeserialize::deserialize(&mut &game_session_data.data[8..]) // Skip anchor discriminator + .expect("Failed to deserialize GameSession"); + + // Verify start_time was reset by compress_as override + // Initial: Clock timestamp (non-zero), After decompress: 0 + assert_ne!( + initial_start_time, 0, + "Initial start_time should have been non-zero" + ); + assert_eq!( + game_session.start_time, 0, + "start_time should be reset to 0 by compress_as override (was: {})", + initial_start_time + ); + + // Extract runtime-specific value (compression_info set during transaction) + let compression_info = game_session.compression_info.clone(); + + // Build expected struct with compress_as overrides applied: + // #[compress_as(start_time = 0, end_time = None, score = 0)] + let expected_game_session = GameSession { + compression_info, // Runtime-specific, extracted from actual + session_id, // 222 - preserved + player: payer.pubkey(), // Preserved + game_type: "Auto Game With Mint".to_string(), // Preserved + start_time: 0, // compress_as override (was Clock timestamp) + end_time: None, // compress_as override + score: 0, // compress_as override + }; + + // Single assert comparing full struct + assert_eq!( + game_session, expected_game_session, + "GameSession should match expected after decompress with compress_as overrides" + ); +} + +/// Test creating 2 mints in a single instruction. +/// Verifies multi-mint support in the RentFree macro. +#[tokio::test] +async fn test_create_two_mints() { + use csdk_anchor_full_derived_test::instruction_accounts::{ + CreateTwoMintsParams, MINT_SIGNER_A_SEED, MINT_SIGNER_B_SEED, + }; + use light_token_sdk::token::{ + find_mint_address as find_cmint_address, COMPRESSIBLE_CONFIG_V1, + RENT_SPONSOR as CTOKEN_RENT_SPONSOR, + }; + + 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 program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + let authority = Keypair::new(); + + // Derive PDAs for both mint signers + let (mint_signer_a_pda, mint_signer_a_bump) = Pubkey::find_program_address( + &[MINT_SIGNER_A_SEED, authority.pubkey().as_ref()], + &program_id, + ); + let (mint_signer_b_pda, mint_signer_b_bump) = Pubkey::find_program_address( + &[MINT_SIGNER_B_SEED, authority.pubkey().as_ref()], + &program_id, + ); + + // Derive mint PDAs + let (cmint_a_pda, _) = find_cmint_address(&mint_signer_a_pda); + let (cmint_b_pda, _) = find_cmint_address(&mint_signer_b_pda); + + // Get proof for both mints + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![ + CreateAccountsProofInput::mint(mint_signer_a_pda), + CreateAccountsProofInput::mint(mint_signer_b_pda), + ], + ) + .await + .unwrap(); + + // Debug: Check proof contents + println!( + "proof_result.create_accounts_proof.proof.0.is_some() = {:?}", + proof_result.create_accounts_proof.proof.0.is_some() + ); + println!( + "proof_result.remaining_accounts.len() = {:?}", + proof_result.remaining_accounts.len() + ); + + let accounts = csdk_anchor_full_derived_test::accounts::CreateTwoMints { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + mint_signer_a: mint_signer_a_pda, + mint_signer_b: mint_signer_b_pda, + cmint_a: cmint_a_pda, + cmint_b: cmint_b_pda, + compression_config: config_pda, + ctoken_compressible_config: COMPRESSIBLE_CONFIG_V1, + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + ctoken_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::CreateTwoMints { + params: CreateTwoMintsParams { + create_accounts_proof: proof_result.create_accounts_proof, + mint_signer_a_bump, + mint_signer_b_bump, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("CreateTwoMints should succeed"); + + // Verify both mints exist on-chain + let cmint_a_account = rpc + .get_account(cmint_a_pda) + .await + .unwrap() + .expect("Mint A should exist on-chain"); + let cmint_b_account = rpc + .get_account(cmint_b_pda) + .await + .unwrap() + .expect("Mint B should exist on-chain"); + + // Parse and verify mint data + use light_token_interface::state::Mint; + let mint_a: Mint = borsh::BorshDeserialize::deserialize(&mut &cmint_a_account.data[..]) + .expect("Failed to deserialize Mint A"); + let mint_b: Mint = borsh::BorshDeserialize::deserialize(&mut &cmint_b_account.data[..]) + .expect("Failed to deserialize Mint B"); + + // Verify decimals match what was specified in #[light_mint] + assert_eq!(mint_a.base.decimals, 6, "Mint A should have 6 decimals"); + assert_eq!(mint_b.base.decimals, 9, "Mint B should have 9 decimals"); + + // Verify mint authorities + assert_eq!( + mint_a.base.mint_authority, + Some(payer.pubkey().to_bytes().into()), + "Mint A authority should be fee_payer" + ); + assert_eq!( + mint_b.base.mint_authority, + Some(payer.pubkey().to_bytes().into()), + "Mint B authority should be fee_payer" + ); + + // Verify compressed addresses registered + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + let mint_a_compressed_address = + light_token_sdk::compressed_token::create_compressed_mint::derive_mint_compressed_address( + &mint_signer_a_pda, + &address_tree_pubkey, + ); + let compressed_mint_a = rpc + .get_compressed_account(mint_a_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + assert_eq!( + compressed_mint_a.address.unwrap(), + mint_a_compressed_address, + "Mint A compressed address should be registered" + ); + + let mint_b_compressed_address = + light_token_sdk::compressed_token::create_compressed_mint::derive_mint_compressed_address( + &mint_signer_b_pda, + &address_tree_pubkey, + ); + let compressed_mint_b = rpc + .get_compressed_account(mint_b_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + assert_eq!( + compressed_mint_b.address.unwrap(), + mint_b_compressed_address, + "Mint B compressed address should be registered" + ); + + // Verify both compressed mint accounts have empty data (decompressed to on-chain) + assert!( + compressed_mint_a.data.as_ref().unwrap().data.is_empty(), + "Mint A compressed data should be empty (decompressed)" + ); + assert!( + compressed_mint_b.data.as_ref().unwrap().data.is_empty(), + "Mint B compressed data should be empty (decompressed)" + ); +} + +/// Test creating 4 mints in a single instruction. +/// Verifies multi-mint support in the RentFree macro scales beyond 2. +#[tokio::test] +async fn test_create_four_mints() { + use csdk_anchor_full_derived_test::instruction_accounts::{ + CreateFourMintsParams, MINT_SIGNER_A_SEED, MINT_SIGNER_B_SEED, MINT_SIGNER_C_SEED, + MINT_SIGNER_D_SEED, + }; + use light_token_sdk::token::{ + find_mint_address as find_cmint_address, COMPRESSIBLE_CONFIG_V1, + RENT_SPONSOR as CTOKEN_RENT_SPONSOR, + }; + + 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 program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + let authority = Keypair::new(); + + // Derive PDAs for all 4 mint signers + let (mint_signer_a_pda, mint_signer_a_bump) = Pubkey::find_program_address( + &[MINT_SIGNER_A_SEED, authority.pubkey().as_ref()], + &program_id, + ); + let (mint_signer_b_pda, mint_signer_b_bump) = Pubkey::find_program_address( + &[MINT_SIGNER_B_SEED, authority.pubkey().as_ref()], + &program_id, + ); + let (mint_signer_c_pda, mint_signer_c_bump) = Pubkey::find_program_address( + &[MINT_SIGNER_C_SEED, authority.pubkey().as_ref()], + &program_id, + ); + let (mint_signer_d_pda, mint_signer_d_bump) = Pubkey::find_program_address( + &[MINT_SIGNER_D_SEED, authority.pubkey().as_ref()], + &program_id, + ); + + // Derive mint PDAs + let (cmint_a_pda, _) = find_cmint_address(&mint_signer_a_pda); + let (cmint_b_pda, _) = find_cmint_address(&mint_signer_b_pda); + let (cmint_c_pda, _) = find_cmint_address(&mint_signer_c_pda); + let (cmint_d_pda, _) = find_cmint_address(&mint_signer_d_pda); + + // Get proof for all 4 mints + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![ + CreateAccountsProofInput::mint(mint_signer_a_pda), + CreateAccountsProofInput::mint(mint_signer_b_pda), + CreateAccountsProofInput::mint(mint_signer_c_pda), + CreateAccountsProofInput::mint(mint_signer_d_pda), + ], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::CreateFourMints { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + mint_signer_a: mint_signer_a_pda, + mint_signer_b: mint_signer_b_pda, + mint_signer_c: mint_signer_c_pda, + mint_signer_d: mint_signer_d_pda, + cmint_a: cmint_a_pda, + cmint_b: cmint_b_pda, + cmint_c: cmint_c_pda, + cmint_d: cmint_d_pda, + compression_config: config_pda, + ctoken_compressible_config: COMPRESSIBLE_CONFIG_V1, + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + ctoken_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::CreateFourMints { + params: CreateFourMintsParams { + create_accounts_proof: proof_result.create_accounts_proof, + mint_signer_a_bump, + mint_signer_b_bump, + mint_signer_c_bump, + mint_signer_d_bump, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("CreateFourMints should succeed"); + + // Verify all 4 mints exist on-chain + use light_token_interface::state::Mint; + + let cmint_a_account = rpc + .get_account(cmint_a_pda) + .await + .unwrap() + .expect("Mint A should exist on-chain"); + let cmint_b_account = rpc + .get_account(cmint_b_pda) + .await + .unwrap() + .expect("Mint B should exist on-chain"); + let cmint_c_account = rpc + .get_account(cmint_c_pda) + .await + .unwrap() + .expect("Mint C should exist on-chain"); + let cmint_d_account = rpc + .get_account(cmint_d_pda) + .await + .unwrap() + .expect("Mint D should exist on-chain"); + + // Parse and verify mint data + let mint_a: Mint = borsh::BorshDeserialize::deserialize(&mut &cmint_a_account.data[..]) + .expect("Failed to deserialize Mint A"); + let mint_b: Mint = borsh::BorshDeserialize::deserialize(&mut &cmint_b_account.data[..]) + .expect("Failed to deserialize Mint B"); + let mint_c: Mint = borsh::BorshDeserialize::deserialize(&mut &cmint_c_account.data[..]) + .expect("Failed to deserialize Mint C"); + let mint_d: Mint = borsh::BorshDeserialize::deserialize(&mut &cmint_d_account.data[..]) + .expect("Failed to deserialize Mint D"); + + // Verify decimals match what was specified in #[light_mint] + assert_eq!(mint_a.base.decimals, 6, "Mint A should have 6 decimals"); + assert_eq!(mint_b.base.decimals, 8, "Mint B should have 8 decimals"); + assert_eq!(mint_c.base.decimals, 9, "Mint C should have 9 decimals"); + assert_eq!(mint_d.base.decimals, 12, "Mint D should have 12 decimals"); + + // Verify mint authorities + assert_eq!( + mint_a.base.mint_authority, + Some(payer.pubkey().to_bytes().into()), + "Mint A authority should be fee_payer" + ); + assert_eq!( + mint_b.base.mint_authority, + Some(payer.pubkey().to_bytes().into()), + "Mint B authority should be fee_payer" + ); + assert_eq!( + mint_c.base.mint_authority, + Some(payer.pubkey().to_bytes().into()), + "Mint C authority should be fee_payer" + ); + assert_eq!( + mint_d.base.mint_authority, + Some(payer.pubkey().to_bytes().into()), + "Mint D authority should be fee_payer" + ); } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs new file mode 100644 index 0000000000..6ef5900474 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs @@ -0,0 +1,1772 @@ +//! Integration tests for D6, D8, and D9 macro test instructions. +//! +//! These tests verify that the macro-generated code works correctly at runtime +//! by testing the full lifecycle: create account -> verify on-chain -> compress -> decompress. + +mod shared; + +use anchor_lang::{InstructionData, ToAccountMetas}; +use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::RentFreeAccountVariant; +use light_compressible::rent::SLOTS_PER_EPOCH; +use light_compressible_client::{ + create_load_accounts_instructions, get_create_accounts_proof, AccountInterface, + AccountInterfaceExt, CreateAccountsProofInput, InitializeRentFreeConfig, + RentFreeDecompressAccount, +}; +use light_macros::pubkey; +use light_program_test::{ + program_test::{setup_mock_program_data, LightProgramTest, TestRpc}, + Indexer, ProgramTestConfig, Rpc, +}; +use light_sdk::compressible::IntoVariant; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +const RENT_SPONSOR: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); + +/// Test context shared across instruction tests +#[allow(dead_code)] +struct TestContext { + rpc: LightProgramTest, + payer: Keypair, + config_pda: Pubkey, + program_id: Pubkey, +} + +impl TestContext { + async fn new() -> Self { + 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 program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + Self { + rpc, + payer, + config_pda, + program_id, + } + } + + async fn assert_onchain_exists(&mut self, pda: &Pubkey) { + assert!( + self.rpc.get_account(*pda).await.unwrap().is_some(), + "Account {} should exist on-chain", + pda + ); + } + + async fn assert_onchain_closed(&mut self, pda: &Pubkey) { + let acc = self.rpc.get_account(*pda).await.unwrap(); + assert!( + acc.is_none() || acc.unwrap().lamports == 0, + "Account {} should be closed", + pda + ); + } + + async fn assert_compressed_exists(&mut self, addr: [u8; 32]) { + let acc = self + .rpc + .get_compressed_account(addr, None) + .await + .unwrap() + .value + .unwrap(); + assert_eq!(acc.address.unwrap(), addr); + assert!(!acc.data.as_ref().unwrap().data.is_empty()); + } + + /// Runs the full compression/decompression lifecycle for a single PDA. + async fn assert_lifecycle(&mut self, pda: &Pubkey, seeds: S) + where + S: IntoVariant, + { + // Warp to trigger compression + self.rpc + .warp_slot_forward(SLOTS_PER_EPOCH * 30) + .await + .unwrap(); + self.assert_onchain_closed(pda).await; + + // Get account interface + let account_interface = self + .rpc + .get_account_info_interface(pda, &self.program_id) + .await + .expect("failed to get account interface"); + assert!( + account_interface.is_cold, + "Account should be cold after compression" + ); + + // Build decompression request + let program_owned_accounts = vec![RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&account_interface), + seeds, + ) + .expect("Seed verification failed")]; + + // Create and execute decompression + let decompress_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[], + &[], + self.program_id, + self.payer.pubkey(), + self.config_pda, + self.payer.pubkey(), + &self.rpc, + ) + .await + .expect("create_load_accounts_instructions should succeed"); + + self.rpc + .create_and_send_transaction( + &decompress_instructions, + &self.payer.pubkey(), + &[&self.payer], + ) + .await + .expect("Decompression should succeed"); + + // Verify account is back on-chain + self.assert_onchain_exists(pda).await; + } + + /// Setup a mint for token-based tests. + /// Returns (mint_pubkey, compression_address, ata_pubkeys, mint_seed_keypair) + #[allow(dead_code)] + async fn setup_mint(&mut self) -> (Pubkey, [u8; 32], Vec, Keypair) { + shared::setup_create_mint( + &mut self.rpc, + &self.payer, + self.payer.pubkey(), // mint_authority + 9, // decimals + vec![], // no recipients initially + ) + .await + } +} + +// ============================================================================= +// D6 Account Types Tests +// ============================================================================= + +/// Tests D6Account: Direct Account<'info, T> type +#[tokio::test] +async fn test_d6_account() { + use csdk_anchor_full_derived_test::d6_account_types::D6AccountParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDA + let (pda, _) = Pubkey::find_program_address(&[b"d6_account", owner.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D6Account { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d6_account_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D6Account { + params: D6AccountParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D6Account instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D6AccountRecordSeeds; + ctx.assert_lifecycle(&pda, D6AccountRecordSeeds { owner }) + .await; +} + +/// Tests D6Boxed: Box> type +#[tokio::test] +async fn test_d6_boxed() { + use csdk_anchor_full_derived_test::d6_account_types::D6BoxedParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDA + let (pda, _) = Pubkey::find_program_address(&[b"d6_boxed", owner.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D6Boxed { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d6_boxed_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D6Boxed { + params: D6BoxedParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D6Boxed instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D6BoxedRecordSeeds; + ctx.assert_lifecycle(&pda, D6BoxedRecordSeeds { owner }) + .await; +} + +// ============================================================================= +// D8 Builder Paths Tests +// ============================================================================= + +/// Tests D8PdaOnly: Only #[rentfree] fields (no token accounts) +#[tokio::test] +async fn test_d8_pda_only() { + use csdk_anchor_full_derived_test::d8_builder_paths::D8PdaOnlyParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDA + let (pda, _) = Pubkey::find_program_address(&[b"d8_pda_only", owner.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D8PdaOnly { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d8_pda_only_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D8PdaOnly { + params: D8PdaOnlyParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D8PdaOnly instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D8PdaOnlyRecordSeeds; + ctx.assert_lifecycle(&pda, D8PdaOnlyRecordSeeds { owner }) + .await; +} + +/// Tests D8MultiRentfree: Multiple #[rentfree] fields of same type +#[tokio::test] +async fn test_d8_multi_rentfree() { + use csdk_anchor_full_derived_test::d8_builder_paths::D8MultiRentfreeParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + let id1 = 111u64; + let id2 = 222u64; + + // Derive PDAs + let (pda1, _) = Pubkey::find_program_address( + &[b"d8_multi_1", owner.as_ref(), id1.to_le_bytes().as_ref()], + &ctx.program_id, + ); + let (pda2, _) = Pubkey::find_program_address( + &[b"d8_multi_2", owner.as_ref(), id2.to_le_bytes().as_ref()], + &ctx.program_id, + ); + + // Get proof for both PDAs + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![ + CreateAccountsProofInput::pda(pda1), + CreateAccountsProofInput::pda(pda2), + ], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D8MultiRentfree { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d8_multi_record1: pda1, + d8_multi_record2: pda2, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D8MultiRentfree { + params: D8MultiRentfreeParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + id1, + id2, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D8MultiRentfree instruction should succeed"); + + // Verify both accounts exist on-chain + ctx.assert_onchain_exists(&pda1).await; + ctx.assert_onchain_exists(&pda2).await; + + // Full lifecycle: compression + decompression (multi-PDA, one at a time) + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ + D8MultiRecord1Seeds, D8MultiRecord2Seeds, + }; + + // Warp to trigger compression + ctx.rpc + .warp_slot_forward(SLOTS_PER_EPOCH * 30) + .await + .unwrap(); + ctx.assert_onchain_closed(&pda1).await; + ctx.assert_onchain_closed(&pda2).await; + + // Decompress first account + let interface1 = ctx + .rpc + .get_account_info_interface(&pda1, &ctx.program_id) + .await + .unwrap(); + let program_owned_accounts = vec![RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&interface1), + D8MultiRecord1Seeds { owner, id1 }, + ) + .unwrap()]; + let decompress_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[], + &[], + ctx.program_id, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), + &ctx.rpc, + ) + .await + .unwrap(); + ctx.rpc + .create_and_send_transaction(&decompress_instructions, &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .unwrap(); + ctx.assert_onchain_exists(&pda1).await; + + // Decompress second account + let interface2 = ctx + .rpc + .get_account_info_interface(&pda2, &ctx.program_id) + .await + .unwrap(); + let program_owned_accounts = vec![RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&interface2), + D8MultiRecord2Seeds { owner, id2 }, + ) + .unwrap()]; + let decompress_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[], + &[], + ctx.program_id, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), + &ctx.rpc, + ) + .await + .unwrap(); + ctx.rpc + .create_and_send_transaction(&decompress_instructions, &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .unwrap(); + ctx.assert_onchain_exists(&pda2).await; +} + +/// Tests D8All: Multiple #[rentfree] fields of different types +#[tokio::test] +async fn test_d8_all() { + use csdk_anchor_full_derived_test::d8_builder_paths::D8AllParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDAs + let (pda_single, _) = + Pubkey::find_program_address(&[b"d8_all_single", owner.as_ref()], &ctx.program_id); + let (pda_multi, _) = + Pubkey::find_program_address(&[b"d8_all_multi", owner.as_ref()], &ctx.program_id); + + // Get proof for both PDAs + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![ + CreateAccountsProofInput::pda(pda_single), + CreateAccountsProofInput::pda(pda_multi), + ], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D8All { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d8_all_single: pda_single, + d8_all_multi: pda_multi, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D8All { + params: D8AllParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D8All instruction should succeed"); + + // Verify both accounts exist on-chain + ctx.assert_onchain_exists(&pda_single).await; + ctx.assert_onchain_exists(&pda_multi).await; + + // Full lifecycle: compression + decompression (multi-PDA, one at a time) + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ + D8AllMultiSeeds, D8AllSingleSeeds, + }; + + // Warp to trigger compression + ctx.rpc + .warp_slot_forward(SLOTS_PER_EPOCH * 30) + .await + .unwrap(); + ctx.assert_onchain_closed(&pda_single).await; + ctx.assert_onchain_closed(&pda_multi).await; + + // Decompress first account (single type) + let interface_single = ctx + .rpc + .get_account_info_interface(&pda_single, &ctx.program_id) + .await + .unwrap(); + let program_owned_accounts = vec![RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&interface_single), + D8AllSingleSeeds { owner }, + ) + .unwrap()]; + let decompress_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[], + &[], + ctx.program_id, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), + &ctx.rpc, + ) + .await + .unwrap(); + ctx.rpc + .create_and_send_transaction(&decompress_instructions, &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .unwrap(); + ctx.assert_onchain_exists(&pda_single).await; + + // Decompress second account (multi type) + let interface_multi = ctx + .rpc + .get_account_info_interface(&pda_multi, &ctx.program_id) + .await + .unwrap(); + let program_owned_accounts = vec![RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&interface_multi), + D8AllMultiSeeds { owner }, + ) + .unwrap()]; + let decompress_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[], + &[], + ctx.program_id, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), + &ctx.rpc, + ) + .await + .unwrap(); + ctx.rpc + .create_and_send_transaction(&decompress_instructions, &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .unwrap(); + ctx.assert_onchain_exists(&pda_multi).await; +} + +// ============================================================================= +// D9 Seeds Tests +// ============================================================================= + +/// Tests D9Literal: Literal seed expression +#[tokio::test] +async fn test_d9_literal() { + use csdk_anchor_full_derived_test::d9_seeds::D9LiteralParams; + + let mut ctx = TestContext::new().await; + + // Derive PDA (literal seeds only) + let (pda, _) = Pubkey::find_program_address(&[b"d9_literal_record"], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9Literal { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d9_literal_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9Literal { + _params: D9LiteralParams { + create_accounts_proof: proof_result.create_accounts_proof, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9Literal instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D9LiteralRecordSeeds; + ctx.assert_lifecycle(&pda, D9LiteralRecordSeeds {}).await; +} + +/// Tests D9Constant: Constant seed expression +#[tokio::test] +async fn test_d9_constant() { + use csdk_anchor_full_derived_test::{d9_seeds::D9ConstantParams, D9_CONSTANT_SEED}; + + let mut ctx = TestContext::new().await; + + // Derive PDA using constant + let (pda, _) = Pubkey::find_program_address(&[D9_CONSTANT_SEED], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9Constant { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d9_constant_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9Constant { + _params: D9ConstantParams { + create_accounts_proof: proof_result.create_accounts_proof, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9Constant instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D9ConstantRecordSeeds; + ctx.assert_lifecycle(&pda, D9ConstantRecordSeeds {}).await; +} + +/// Tests D9CtxAccount: Context account seed expression +#[tokio::test] +async fn test_d9_ctx_account() { + use csdk_anchor_full_derived_test::d9_seeds::D9CtxAccountParams; + + let mut ctx = TestContext::new().await; + let authority = Keypair::new(); + + // Derive PDA using authority key + let (pda, _) = + Pubkey::find_program_address(&[b"d9_ctx", authority.pubkey().as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9CtxAccount { + fee_payer: ctx.payer.pubkey(), + authority: authority.pubkey(), + compression_config: ctx.config_pda, + d9_ctx_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9CtxAccount { + _params: D9CtxAccountParams { + create_accounts_proof: proof_result.create_accounts_proof, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9CtxAccount instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D9CtxRecordSeeds; + ctx.assert_lifecycle( + &pda, + D9CtxRecordSeeds { + authority: authority.pubkey(), + }, + ) + .await; +} + +/// Tests D9Param: Param seed expression (Pubkey) +#[tokio::test] +async fn test_d9_param() { + use csdk_anchor_full_derived_test::d9_seeds::D9ParamParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDA using param + let (pda, _) = Pubkey::find_program_address(&[b"d9_param", owner.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9Param { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d9_param_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9Param { + params: D9ParamParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9Param instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D9ParamRecordSeeds; + ctx.assert_lifecycle(&pda, D9ParamRecordSeeds { owner }) + .await; +} + +/// Tests D9ParamBytes: Param bytes seed expression (u64) +#[tokio::test] +async fn test_d9_param_bytes() { + use csdk_anchor_full_derived_test::d9_seeds::D9ParamBytesParams; + + let mut ctx = TestContext::new().await; + let id = 12345u64; + + // Derive PDA using param bytes + let (pda, _) = Pubkey::find_program_address( + &[b"d9_param_bytes", id.to_le_bytes().as_ref()], + &ctx.program_id, + ); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9ParamBytes { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d9_param_bytes_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9ParamBytes { + _params: D9ParamBytesParams { + create_accounts_proof: proof_result.create_accounts_proof, + id, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9ParamBytes instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D9ParamBytesRecordSeeds; + ctx.assert_lifecycle(&pda, D9ParamBytesRecordSeeds { id }) + .await; +} + +/// Tests D9Mixed: Mixed seed expression types +#[tokio::test] +async fn test_d9_mixed() { + use csdk_anchor_full_derived_test::d9_seeds::D9MixedParams; + + let mut ctx = TestContext::new().await; + let authority = Keypair::new(); + let owner = Keypair::new().pubkey(); + + // Derive PDA using mixed seeds + let (pda, _) = Pubkey::find_program_address( + &[b"d9_mixed", authority.pubkey().as_ref(), owner.as_ref()], + &ctx.program_id, + ); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9Mixed { + fee_payer: ctx.payer.pubkey(), + authority: authority.pubkey(), + compression_config: ctx.config_pda, + d9_mixed_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9Mixed { + params: D9MixedParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9Mixed instruction should succeed"); + + // Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D9MixedRecordSeeds; + ctx.assert_lifecycle( + &pda, + D9MixedRecordSeeds { + authority: authority.pubkey(), + owner, + }, + ) + .await; +} + +// ============================================================================= +// D7 Infrastructure Names Tests +// ============================================================================= + +/// Tests D7Payer: "payer" field name variant +#[tokio::test] +async fn test_d7_payer() { + use csdk_anchor_full_derived_test::d7_infra_names::D7PayerParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDA + let (pda, _) = Pubkey::find_program_address(&[b"d7_payer", owner.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D7Payer { + payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d7_payer_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D7Payer { + params: D7PayerParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D7Payer instruction should succeed"); + + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D7PayerRecordSeeds; + ctx.assert_lifecycle(&pda, D7PayerRecordSeeds { owner }) + .await; +} + +/// Tests D7Creator: "creator" field name variant +#[tokio::test] +async fn test_d7_creator() { + use csdk_anchor_full_derived_test::d7_infra_names::D7CreatorParams; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDA + let (pda, _) = Pubkey::find_program_address(&[b"d7_creator", owner.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D7Creator { + creator: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d7_creator_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D7Creator { + params: D7CreatorParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D7Creator instruction should succeed"); + + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D7CreatorRecordSeeds; + ctx.assert_lifecycle(&pda, D7CreatorRecordSeeds { owner }) + .await; +} + +// ============================================================================= +// D9 Additional Seeds Tests +// ============================================================================= + +/// Tests D9FunctionCall: Function call seed expression +#[tokio::test] +async fn test_d9_function_call() { + use csdk_anchor_full_derived_test::d9_seeds::D9FunctionCallParams; + + let mut ctx = TestContext::new().await; + let key_a = Keypair::new().pubkey(); + let key_b = Keypair::new().pubkey(); + + // Derive PDA using max_key (same as in instruction) + let max_key = csdk_anchor_full_derived_test::max_key(&key_a, &key_b); + let (pda, _) = Pubkey::find_program_address(&[b"d9_func", max_key.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9FunctionCall { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d9_func_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9FunctionCall { + params: D9FunctionCallParams { + create_accounts_proof: proof_result.create_accounts_proof, + key_a, + key_b, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9FunctionCall instruction should succeed"); + + ctx.assert_onchain_exists(&pda).await; + + // Full lifecycle: compression + decompression + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D9FuncRecordSeeds; + ctx.assert_lifecycle(&pda, D9FuncRecordSeeds { key_a, key_b }) + .await; +} + +/// Tests D9All: All 6 seed expression types +#[tokio::test] +async fn test_d9_all() { + use csdk_anchor_full_derived_test::{d9_seeds::D9AllParams, D9_ALL_SEED}; + + let mut ctx = TestContext::new().await; + let authority = Keypair::new(); + let owner = Keypair::new().pubkey(); + let id = 42u64; + let key_a = Keypair::new().pubkey(); + let key_b = Keypair::new().pubkey(); + + // Derive all 6 PDAs + let (pda_lit, _) = Pubkey::find_program_address(&[b"d9_all_lit"], &ctx.program_id); + let (pda_const, _) = Pubkey::find_program_address(&[D9_ALL_SEED], &ctx.program_id); + let (pda_ctx, _) = Pubkey::find_program_address( + &[b"d9_all_ctx", authority.pubkey().as_ref()], + &ctx.program_id, + ); + let (pda_param, _) = + Pubkey::find_program_address(&[b"d9_all_param", owner.as_ref()], &ctx.program_id); + let (pda_bytes, _) = Pubkey::find_program_address( + &[b"d9_all_bytes", id.to_le_bytes().as_ref()], + &ctx.program_id, + ); + let max_key = csdk_anchor_full_derived_test::max_key(&key_a, &key_b); + let (pda_func, _) = + Pubkey::find_program_address(&[b"d9_all_func", max_key.as_ref()], &ctx.program_id); + + // Get proof for all 6 PDAs + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![ + CreateAccountsProofInput::pda(pda_lit), + CreateAccountsProofInput::pda(pda_const), + CreateAccountsProofInput::pda(pda_ctx), + CreateAccountsProofInput::pda(pda_param), + CreateAccountsProofInput::pda(pda_bytes), + CreateAccountsProofInput::pda(pda_func), + ], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D9All { + fee_payer: ctx.payer.pubkey(), + authority: authority.pubkey(), + compression_config: ctx.config_pda, + d9_all_lit: pda_lit, + d9_all_const: pda_const, + d9_all_ctx: pda_ctx, + d9_all_param: pda_param, + d9_all_bytes: pda_bytes, + d9_all_func: pda_func, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9All { + params: D9AllParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + id, + key_a, + key_b, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D9All instruction should succeed"); + + // Verify all 6 accounts exist + ctx.assert_onchain_exists(&pda_lit).await; + ctx.assert_onchain_exists(&pda_const).await; + ctx.assert_onchain_exists(&pda_ctx).await; + ctx.assert_onchain_exists(&pda_param).await; + ctx.assert_onchain_exists(&pda_bytes).await; + ctx.assert_onchain_exists(&pda_func).await; + + // Full lifecycle: compression + decompression (6 PDAs, one at a time) + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ + D9AllBytesSeeds, D9AllConstSeeds, D9AllCtxSeeds, D9AllFuncSeeds, D9AllLitSeeds, + D9AllParamSeeds, + }; + + // Warp to trigger compression + ctx.rpc + .warp_slot_forward(SLOTS_PER_EPOCH * 30) + .await + .unwrap(); + ctx.assert_onchain_closed(&pda_lit).await; + ctx.assert_onchain_closed(&pda_const).await; + ctx.assert_onchain_closed(&pda_ctx).await; + ctx.assert_onchain_closed(&pda_param).await; + ctx.assert_onchain_closed(&pda_bytes).await; + ctx.assert_onchain_closed(&pda_func).await; + + // Helper to decompress a single account + async fn decompress_one>( + ctx: &mut TestContext, + pda: &Pubkey, + seeds: S, + ) { + let interface = ctx + .rpc + .get_account_info_interface(pda, &ctx.program_id) + .await + .unwrap(); + let program_owned_accounts = + vec![ + RentFreeDecompressAccount::from_seeds(AccountInterface::from(&interface), seeds) + .unwrap(), + ]; + let decompress_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[], + &[], + ctx.program_id, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), + &ctx.rpc, + ) + .await + .unwrap(); + ctx.rpc + .create_and_send_transaction( + &decompress_instructions, + &ctx.payer.pubkey(), + &[&ctx.payer], + ) + .await + .unwrap(); + ctx.assert_onchain_exists(pda).await; + } + + // Decompress all 6 accounts one at a time + decompress_one(&mut ctx, &pda_lit, D9AllLitSeeds {}).await; + decompress_one(&mut ctx, &pda_const, D9AllConstSeeds {}).await; + decompress_one( + &mut ctx, + &pda_ctx, + D9AllCtxSeeds { + authority: authority.pubkey(), + }, + ) + .await; + decompress_one(&mut ctx, &pda_param, D9AllParamSeeds { owner }).await; + decompress_one(&mut ctx, &pda_bytes, D9AllBytesSeeds { id }).await; + decompress_one(&mut ctx, &pda_func, D9AllFuncSeeds { key_a, key_b }).await; +} + +// ============================================================================= +// Full Lifecycle Test (compression + decompression) +// ============================================================================= + +/// Tests the full lifecycle with compression and decompression +#[tokio::test] +async fn test_d8_pda_only_full_lifecycle() { + use csdk_anchor_full_derived_test::{ + csdk_anchor_full_derived_test::D8PdaOnlyRecordSeeds, d8_builder_paths::D8PdaOnlyParams, + }; + use light_compressible::rent::SLOTS_PER_EPOCH; + use light_compressible_client::{ + create_load_accounts_instructions, AccountInterface, AccountInterfaceExt, + RentFreeDecompressAccount, + }; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Derive PDA + let (pda, _) = Pubkey::find_program_address(&[b"d8_pda_only", owner.as_ref()], &ctx.program_id); + + // Get proof + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(pda)], + ) + .await + .unwrap(); + + // Build and send instruction + let accounts = csdk_anchor_full_derived_test::accounts::D8PdaOnly { + fee_payer: ctx.payer.pubkey(), + compression_config: ctx.config_pda, + d8_pda_only_record: pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D8PdaOnly { + params: D8PdaOnlyParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D8PdaOnly instruction should succeed"); + + // PHASE 1: Verify account exists on-chain + ctx.assert_onchain_exists(&pda).await; + + // PHASE 2: Warp to trigger auto-compression + ctx.rpc + .warp_slot_forward(SLOTS_PER_EPOCH * 30) + .await + .unwrap(); + + // Verify account is compressed (on-chain closed) + ctx.assert_onchain_closed(&pda).await; + + // Derive compressed address + let address_tree_pubkey = ctx.rpc.get_address_tree_v2().tree; + let compressed_address = light_compressed_account::address::derive_address( + &pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &ctx.program_id.to_bytes(), + ); + + // Verify compressed account exists with data + ctx.assert_compressed_exists(compressed_address).await; + + // PHASE 3: Decompress account + let account_interface = ctx + .rpc + .get_account_info_interface(&pda, &ctx.program_id) + .await + .expect("failed to get account interface"); + assert!(account_interface.is_cold, "Account should be cold"); + + let program_owned_accounts = vec![RentFreeDecompressAccount::from_seeds( + AccountInterface::from(&account_interface), + D8PdaOnlyRecordSeeds { owner }, + ) + .expect("Seed verification failed")]; + + let decompress_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[], + &[], + ctx.program_id, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), + &ctx.rpc, + ) + .await + .expect("create_load_accounts_instructions should succeed"); + + ctx.rpc + .create_and_send_transaction(&decompress_instructions, &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("Decompression should succeed"); + + // PHASE 4: Verify account is back on-chain + ctx.assert_onchain_exists(&pda).await; +} + +// ============================================================================= +// D5 Markers Token Tests (require mint setup) +// ============================================================================= + +/// Tests D5RentfreeToken: #[rentfree_token] attribute +/// NOTE: This test is skipped because token-only instructions (no #[rentfree] PDAs) +/// still require a CreateAccountsProof but get_create_accounts_proof fails with empty inputs. +#[tokio::test] +async fn test_d5_rentfree_token() { + use csdk_anchor_full_derived_test::d5_markers::{ + D5RentfreeTokenParams, D5_VAULT_AUTH_SEED, D5_VAULT_SEED, + }; + use light_sdk_types::LIGHT_TOKEN_PROGRAM_ID; + use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + + let mut ctx = TestContext::new().await; + + // Setup mint + let (mint, _compression_addr, _atas, _mint_seed) = ctx.setup_mint().await; + + // Derive PDAs + let (vault_authority, _) = Pubkey::find_program_address(&[D5_VAULT_AUTH_SEED], &ctx.program_id); + let (vault, vault_bump) = + Pubkey::find_program_address(&[D5_VAULT_SEED, mint.as_ref()], &ctx.program_id); + + // Get proof (no PDA accounts for token-only instruction) + let proof_result = get_create_accounts_proof(&ctx.rpc, &ctx.program_id, vec![]) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D5RentfreeToken { + fee_payer: ctx.payer.pubkey(), + mint, + vault_authority, + d5_token_vault: vault, + ctoken_compressible_config: COMPRESSIBLE_CONFIG_V1, + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + ctoken_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D5RentfreeToken { + params: D5RentfreeTokenParams { + create_accounts_proof: proof_result.create_accounts_proof, + vault_bump, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D5RentfreeToken instruction should succeed"); + + // Verify token vault exists + ctx.assert_onchain_exists(&vault).await; + + // Note: Token vault decompression not tested - requires TokenAccountVariant +} + +/// Tests D5AllMarkers: #[rentfree] + #[rentfree_token] combined +#[tokio::test] +async fn test_d5_all_markers() { + use csdk_anchor_full_derived_test::d5_markers::{ + D5AllMarkersParams, D5_ALL_AUTH_SEED, D5_ALL_VAULT_SEED, + }; + use light_sdk_types::LIGHT_TOKEN_PROGRAM_ID; + use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Setup mint + let (mint, _compression_addr, _atas, _mint_seed) = ctx.setup_mint().await; + + // Derive PDAs + let (d5_all_authority, _) = Pubkey::find_program_address(&[D5_ALL_AUTH_SEED], &ctx.program_id); + let (d5_all_record, _) = + Pubkey::find_program_address(&[b"d5_all_record", owner.as_ref()], &ctx.program_id); + let (d5_all_vault, _) = + Pubkey::find_program_address(&[D5_ALL_VAULT_SEED, mint.as_ref()], &ctx.program_id); + + // Get proof for PDA record + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(d5_all_record)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D5AllMarkers { + fee_payer: ctx.payer.pubkey(), + mint, + compression_config: ctx.config_pda, + d5_all_authority, + d5_all_record, + d5_all_vault, + ctoken_compressible_config: COMPRESSIBLE_CONFIG_V1, + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + ctoken_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D5AllMarkers { + params: D5AllMarkersParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D5AllMarkers instruction should succeed"); + + // Verify both PDA record and token vault exist + ctx.assert_onchain_exists(&d5_all_record).await; + ctx.assert_onchain_exists(&d5_all_vault).await; + + // Full lifecycle: compression + decompression (PDA only) + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D5AllRecordSeeds; + ctx.assert_lifecycle(&d5_all_record, D5AllRecordSeeds { owner }) + .await; + // Note: Token vault decompression not tested - requires TokenAccountVariant +} + +// ============================================================================= +// D7 Infrastructure Names Token Tests (require mint setup) +// ============================================================================= + +/// Tests D7CtokenConfig: ctoken_compressible_config/ctoken_rent_sponsor naming +/// Token-only instruction (no #[rentfree] PDAs) - verifies infrastructure field naming. +#[tokio::test] +async fn test_d7_ctoken_config() { + use csdk_anchor_full_derived_test::d7_infra_names::{ + D7CtokenConfigParams, D7_CTOKEN_AUTH_SEED, D7_CTOKEN_VAULT_SEED, + }; + use light_sdk_types::LIGHT_TOKEN_PROGRAM_ID; + use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + + let mut ctx = TestContext::new().await; + + // Setup mint + let (mint, _compression_addr, _atas, _mint_seed) = ctx.setup_mint().await; + + // Derive PDAs + let (d7_ctoken_authority, _) = + Pubkey::find_program_address(&[D7_CTOKEN_AUTH_SEED], &ctx.program_id); + let (d7_ctoken_vault, _) = + Pubkey::find_program_address(&[D7_CTOKEN_VAULT_SEED, mint.as_ref()], &ctx.program_id); + + // Get proof (no PDA accounts for token-only instruction) + let proof_result = get_create_accounts_proof(&ctx.rpc, &ctx.program_id, vec![]) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D7CtokenConfig { + fee_payer: ctx.payer.pubkey(), + mint, + d7_ctoken_authority, + d7_ctoken_vault, + ctoken_compressible_config: COMPRESSIBLE_CONFIG_V1, + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + ctoken_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D7CtokenConfig { + _params: D7CtokenConfigParams { + create_accounts_proof: proof_result.create_accounts_proof, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D7CtokenConfig instruction should succeed"); + + // Verify token vault exists + ctx.assert_onchain_exists(&d7_ctoken_vault).await; + + // Note: Token vault decompression not tested - requires TokenAccountVariant +} + +/// Tests D7AllNames: payer + ctoken_config/rent_sponsor naming combined +#[tokio::test] +async fn test_d7_all_names() { + use csdk_anchor_full_derived_test::d7_infra_names::{ + D7AllNamesParams, D7_ALL_AUTH_SEED, D7_ALL_VAULT_SEED, + }; + use light_sdk_types::LIGHT_TOKEN_PROGRAM_ID; + use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as CTOKEN_RENT_SPONSOR}; + + let mut ctx = TestContext::new().await; + let owner = Keypair::new().pubkey(); + + // Setup mint + let (mint, _compression_addr, _atas, _mint_seed) = ctx.setup_mint().await; + + // Derive PDAs + let (d7_all_authority, _) = Pubkey::find_program_address(&[D7_ALL_AUTH_SEED], &ctx.program_id); + let (d7_all_record, _) = + Pubkey::find_program_address(&[b"d7_all_record", owner.as_ref()], &ctx.program_id); + let (d7_all_vault, _) = + Pubkey::find_program_address(&[D7_ALL_VAULT_SEED, mint.as_ref()], &ctx.program_id); + + // Get proof for PDA record + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(d7_all_record)], + ) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D7AllNames { + payer: ctx.payer.pubkey(), + mint, + compression_config: ctx.config_pda, + d7_all_authority, + d7_all_record, + d7_all_vault, + ctoken_compressible_config: COMPRESSIBLE_CONFIG_V1, + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + ctoken_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D7AllNames { + params: D7AllNamesParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D7AllNames instruction should succeed"); + + // Verify both PDA record and token vault exist + ctx.assert_onchain_exists(&d7_all_record).await; + ctx.assert_onchain_exists(&d7_all_vault).await; + + // Full lifecycle: compression + decompression (PDA only) + use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::D7AllRecordSeeds; + ctx.assert_lifecycle(&d7_all_record, D7AllRecordSeeds { owner }) + .await; + // Note: Token vault decompression not tested - requires TokenAccountVariant +} diff --git a/sdk-tests/sdk-token-test/Cargo.toml b/sdk-tests/sdk-token-test/Cargo.toml index 9871497514..41b64523e7 100644 --- a/sdk-tests/sdk-token-test/Cargo.toml +++ b/sdk-tests/sdk-token-test/Cargo.toml @@ -34,10 +34,12 @@ profile-heap = [ light-token-sdk = { workspace = true, features = ["anchor", "cpi-context", "v1"] } light-token-types = { workspace = true } anchor-lang = { workspace = true } +solana-pubkey = { workspace = true } +solana-account-info = { workspace = true } light-hasher = { workspace = true } light-sdk = { workspace = true, features = ["v2", "cpi-context"] } light-sdk-types = { workspace = true, features = ["cpi-context"] } -light-compressed-account = { workspace = true, features = ["std"] } +light-compressed-account = { workspace = true, features = ["std", "anchor"] } arrayvec = { workspace = true } light-batched-merkle-tree = { workspace = true } light-token-interface = { workspace = true, features = ["anchor"] } diff --git a/sdk-tests/sdk-token-test/src/lib.rs b/sdk-tests/sdk-token-test/src/lib.rs index 560cb7c44e..245112a40d 100644 --- a/sdk-tests/sdk-token-test/src/lib.rs +++ b/sdk-tests/sdk-token-test/src/lib.rs @@ -18,6 +18,7 @@ mod process_compress_tokens; mod process_create_compressed_account; mod process_create_ctoken_with_compress_to_pubkey; mod process_create_escrow_pda; +mod process_create_two_mints; mod process_decompress_full_cpi_context; mod process_decompress_tokens; mod process_four_invokes; @@ -34,6 +35,8 @@ use process_compress_tokens::process_compress_tokens; use process_create_compressed_account::process_create_compressed_account; use process_create_ctoken_with_compress_to_pubkey::process_create_ctoken_with_compress_to_pubkey; use process_create_escrow_pda::process_create_escrow_pda; +use process_create_two_mints::process_create_mints; +pub use process_create_two_mints::{CreateMintsParams, MintParams}; use process_decompress_full_cpi_context::process_decompress_full_cpi_context; use process_decompress_tokens::process_decompress_tokens; use process_four_invokes::process_four_invokes; @@ -338,6 +341,18 @@ pub mod sdk_token_test { ) -> Result<()> { process_ctoken_pda(ctx, input) } + + /// Create one or more compressed mints and decompress all to Solana accounts. + /// + /// Flow: + /// - N=1: Single CPI (create + decompress) + /// - N>1: 2N-1 CPIs (N-1 writes + 1 execute with decompress + N-1 decompress) + pub fn create_mints<'a, 'info>( + ctx: Context<'a, '_, 'info, 'info, Generic<'info>>, + params: CreateMintsParams, + ) -> Result<()> { + process_create_mints(ctx, params) + } } #[derive(Accounts)] diff --git a/sdk-tests/sdk-token-test/src/process_create_two_mints.rs b/sdk-tests/sdk-token-test/src/process_create_two_mints.rs new file mode 100644 index 0000000000..6c950232ee --- /dev/null +++ b/sdk-tests/sdk-token-test/src/process_create_two_mints.rs @@ -0,0 +1,75 @@ +use anchor_lang::prelude::*; +use light_token_sdk::{ + token::{create_mints, CreateMintsParams as SdkCreateMintsParams, SingleMintParams}, + CompressedProof, +}; + +/// Parameters for a single mint within a batch creation. +/// Does not include proof since proof is shared across all mints. +#[derive(Clone, AnchorSerialize, AnchorDeserialize)] +pub struct MintParams { + pub decimals: u8, + pub address_merkle_tree_root_index: u16, + pub mint_authority: Pubkey, + pub compression_address: [u8; 32], + pub mint: Pubkey, + pub bump: u8, + pub freeze_authority: Option, + pub mint_seed_pubkey: Pubkey, +} + +/// Parameters for creating one or more compressed mints with decompression. +/// +/// Creates N compressed mints and decompresses all to Solana Mint accounts. +/// Uses CPI context pattern when N > 1 for efficiency. +/// +/// Flow: +/// - N=1: Single CPI (create + decompress) +/// - N>1: 2N-1 CPIs (N-1 writes + 1 execute with decompress + N-1 decompress) +#[derive(Clone, AnchorSerialize, AnchorDeserialize)] +pub struct CreateMintsParams { + /// Parameters for each mint to create + pub mints: Vec, + /// Single proof covering all new addresses + pub proof: CompressedProof, +} + +impl CreateMintsParams { + pub fn new(mints: Vec, proof: CompressedProof) -> Self { + Self { mints, proof } + } +} + +/// Anchor instruction wrapper for create_mints. +pub fn process_create_mints<'a, 'info>( + ctx: Context<'a, '_, 'info, 'info, crate::Generic<'info>>, + params: CreateMintsParams, +) -> Result<()> { + // Convert anchor types to SDK types + let sdk_mints: Vec> = params + .mints + .iter() + .map(|m| SingleMintParams { + decimals: m.decimals, + address_merkle_tree_root_index: m.address_merkle_tree_root_index, + mint_authority: solana_pubkey::Pubkey::new_from_array(m.mint_authority.to_bytes()), + compression_address: m.compression_address, + mint: solana_pubkey::Pubkey::new_from_array(m.mint.to_bytes()), + bump: m.bump, + freeze_authority: m + .freeze_authority + .map(|a| solana_pubkey::Pubkey::new_from_array(a.to_bytes())), + mint_seed_pubkey: solana_pubkey::Pubkey::new_from_array(m.mint_seed_pubkey.to_bytes()), + authority_seeds: None, + mint_signer_seeds: None, + }) + .collect(); + + let sdk_params = SdkCreateMintsParams::new(&sdk_mints, params.proof); + + let payer = ctx.accounts.signer.to_account_info(); + create_mints(&payer, ctx.remaining_accounts, sdk_params) + .map_err(|_| anchor_lang::error::ErrorCode::InstructionDidNotSerialize)?; + + Ok(()) +} diff --git a/sdk-tests/sdk-token-test/tests/test_create_two_mints.rs b/sdk-tests/sdk-token-test/tests/test_create_two_mints.rs new file mode 100644 index 0000000000..680327ce0e --- /dev/null +++ b/sdk-tests/sdk-token-test/tests/test_create_two_mints.rs @@ -0,0 +1,160 @@ +use anchor_lang::InstructionData; +use light_program_test::{AddressWithTree, Indexer, LightProgramTest, ProgramTestConfig, Rpc}; +use light_token_sdk::token::{ + config_pda, derive_mint_compressed_address, find_mint_address, rent_sponsor_pda, + SystemAccounts, LIGHT_TOKEN_PROGRAM_ID, +}; +use sdk_token_test::{CreateMintsParams, MintParams}; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + signature::{Keypair, Signer}, +}; + +#[tokio::test] +async fn test_create_single_mint() { + test_create_mints(1).await; +} + +#[tokio::test] +async fn test_create_two_mints() { + test_create_mints(2).await; +} + +#[tokio::test] +async fn test_create_three_mints() { + test_create_mints(3).await; +} + +async fn test_create_mints(n: usize) { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2( + false, + Some(vec![("sdk_token_test", sdk_token_test::ID)]), + )) + .await + .unwrap(); + + let payer = rpc.get_payer().insecure_clone(); + let mint_signers: Vec = (0..n).map(|_| Keypair::new()).collect(); + + let address_tree_info = rpc.get_address_tree_v2(); + let state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + let compression_addresses: Vec<[u8; 32]> = mint_signers + .iter() + .map(|signer| derive_mint_compressed_address(&signer.pubkey(), &address_tree_info.tree)) + .collect(); + + let mint_pdas: Vec<(solana_sdk::pubkey::Pubkey, u8)> = mint_signers + .iter() + .map(|signer| find_mint_address(&signer.pubkey())) + .collect(); + + let addresses_with_trees: Vec = compression_addresses + .iter() + .map(|addr| AddressWithTree { + address: *addr, + tree: address_tree_info.tree, + }) + .collect(); + + let proof_result = rpc + .get_validity_proof(vec![], addresses_with_trees, None) + .await + .unwrap() + .value; + + let mints: Vec = mint_signers + .iter() + .zip(compression_addresses.iter()) + .zip(mint_pdas.iter()) + .enumerate() + .map( + |(i, ((signer, compression_address), (mint_pda, bump)))| MintParams { + decimals: (6 + i) as u8, + address_merkle_tree_root_index: proof_result.addresses[i].root_index, + mint_authority: payer.pubkey(), + compression_address: *compression_address, + mint: *mint_pda, + bump: *bump, + freeze_authority: None, + mint_seed_pubkey: signer.pubkey(), + }, + ) + .collect(); + + let params = CreateMintsParams::new(mints, proof_result.proof.0.unwrap()); + + let system_accounts = SystemAccounts::default(); + let cpi_context_pubkey = state_tree_info + .cpi_context + .expect("CPI context account required"); + + // Account layout (remaining_accounts) must match SDK's create_mints expected order: + // [0]: light_system_program + // [1..N+1]: mint_signers (SIGNER) + // [N+1..N+6]: system PDAs (cpi_authority, registered_program, compression_authority, compression_program, system_program) + // [N+6]: cpi_context_account + // [N+7]: output_queue + // [N+8]: state_merkle_tree + // [N+9]: address_tree (must be at index 1 in tree accounts for create_mint validation) + // [N+10]: compressible_config + // [N+11]: rent_sponsor + // [N+12..2N+12]: mint_pdas + // [2N+12]: compressed_token_program (for CPI) + let mut accounts = vec![ + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new_readonly(system_accounts.light_system_program, false), + ]; + + for signer in &mint_signers { + accounts.push(AccountMeta::new_readonly(signer.pubkey(), true)); + } + + accounts.extend(vec![ + AccountMeta::new_readonly(system_accounts.cpi_authority_pda, false), + AccountMeta::new_readonly(system_accounts.registered_program_pda, false), + AccountMeta::new_readonly(system_accounts.account_compression_authority, false), + AccountMeta::new_readonly(system_accounts.account_compression_program, false), + AccountMeta::new_readonly(system_accounts.system_program, false), + AccountMeta::new(cpi_context_pubkey, false), + AccountMeta::new(state_tree_info.queue, false), // output_queue at [N+7] + AccountMeta::new(state_tree_info.tree, false), // state_merkle_tree at [N+8] + AccountMeta::new(address_tree_info.tree, false), // address_tree at [N+9] + AccountMeta::new_readonly(config_pda(), false), + AccountMeta::new(rent_sponsor_pda(), false), + ]); + + for (mint_pda, _) in &mint_pdas { + accounts.push(AccountMeta::new(*mint_pda, false)); + } + + // Append compressed token program at the end for CPI + accounts.push(AccountMeta::new_readonly(LIGHT_TOKEN_PROGRAM_ID, false)); + + let instruction = Instruction { + program_id: sdk_token_test::ID, + accounts, + data: sdk_token_test::instruction::CreateMints { params }.data(), + }; + + let mut signers: Vec<&Keypair> = vec![&payer]; + signers.extend(mint_signers.iter()); + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &signers) + .await + .unwrap(); + + for (i, (mint_pda, _)) in mint_pdas.iter().enumerate() { + let mint_account = rpc + .get_account(*mint_pda) + .await + .expect("Failed to get mint account") + .unwrap_or_else(|| panic!("Mint PDA {} should exist after decompress", i + 1)); + + assert!( + !mint_account.data.is_empty(), + "Mint {} account should have data", + i + 1 + ); + } +}