diff --git a/.gitignore b/.gitignore index 48f4fbd9be..f2564480b7 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ output1.txt **/~/ expand.rs +output.rs diff --git a/Cargo.lock b/Cargo.lock index 85eafabf0d..8531f685da 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=d8a2b3d9#d8a2b3d99d61ef900d1f6cdaabcef14eb9af6279" +source = "git+https://github.com/lightprotocol/anchor?rev=4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3#4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3" 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=d8a2b3d9)", + "anchor-spl 0.31.1 (git+https://github.com/lightprotocol/anchor?rev=4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3)", "bincode", "borsh 0.10.4", "light-client", diff --git a/sdk-libs/macros/CLAUDE.md b/sdk-libs/macros/CLAUDE.md new file mode 100644 index 0000000000..dfb6f9724c --- /dev/null +++ b/sdk-libs/macros/CLAUDE.md @@ -0,0 +1,94 @@ +# light-sdk-macros + +Procedural macros for Light Protocol's rent-free compression system. + +## Crate Overview + +This crate provides macros that enable rent-free compressed accounts on Solana with minimal boilerplate. + +**Package**: `light-sdk-macros` +**Location**: `sdk-libs/macros/` + +## Main Macros + +| Macro | Type | Purpose | +|-------|------|---------| +| `#[derive(RentFree)]` | Derive | Generates `LightPreInit`/`LightFinalize` for Accounts structs | +| `#[rentfree_program]` | Attribute | Program-level auto-discovery and instruction generation | +| `#[derive(LightCompressible)]` | Derive | Combined traits for compressible account data | +| `#[derive(Compressible)]` | Derive | Compression traits (HasCompressionInfo, CompressAs, Size) | +| `#[derive(CompressiblePack)]` | Derive | Pack/Unpack with Pubkey-to-index compression | + +## Documentation + +Detailed macro documentation is in the `docs/` directory: + +- **`docs/CLAUDE.md`** - Documentation structure guide +- **`docs/rentfree.md`** - `#[derive(RentFree)]` and trait derives +- **`docs/rentfree_program/`** - `#[rentfree_program]` attribute macro (architecture.md + codegen.md) + +## Source Structure + +``` +src/ +├── lib.rs # Macro entry points +├── rentfree/ # RentFree macro system +│ ├── accounts/ # #[derive(RentFree)] for Accounts structs +│ ├── program/ # #[rentfree_program] attribute macro +│ ├── traits/ # Trait derive macros +│ └── shared_utils.rs # Common utilities +└── hasher/ # LightHasherSha derive macro +``` + +## Usage Example + +```rust +use light_sdk_macros::{rentfree_program, RentFree, LightCompressible}; + +// State account with compression support +#[derive(Default, Debug, InitSpace, LightCompressible)] +#[account] +pub struct UserRecord { + pub owner: Pubkey, + pub score: u64, + pub compression_info: Option, +} + +// Accounts struct with rent-free field +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct Create<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[account(init, payer = fee_payer, space = 8 + UserRecord::INIT_SPACE, seeds = [b"user", params.owner.as_ref()], bump)] + #[rentfree] + pub user_record: Account<'info, UserRecord>, +} + +// Program with auto-wrapped instructions +#[rentfree_program] +#[program] +pub mod my_program { + pub fn create(ctx: Context, params: CreateParams) -> Result<()> { + // Business logic - compression handled automatically + ctx.accounts.user_record.owner = params.owner; + Ok(()) + } +} +``` + +## Requirements + +Programs using these macros must define: +- `LIGHT_CPI_SIGNER: Pubkey` - CPI signer constant +- `ID` - Program ID (from `declare_id!`) + +## Testing + +```bash +cargo test -p light-sdk-macros +``` + +Integration tests are in `sdk-tests/`: +- `csdk-anchor-full-derived-test` - Full macro integration test diff --git a/sdk-libs/macros/docs/CLAUDE.md b/sdk-libs/macros/docs/CLAUDE.md new file mode 100644 index 0000000000..5d68b4814e --- /dev/null +++ b/sdk-libs/macros/docs/CLAUDE.md @@ -0,0 +1,72 @@ +# Documentation Structure + +## Overview + +Documentation for the rentfree macro system in `light-sdk-macros`. These macros enable rent-free compressed accounts on Solana with minimal boilerplate. + +## Structure + +| File | Description | +|------|-------------| +| **`CLAUDE.md`** | This file - documentation structure guide | +| **`../CLAUDE.md`** | Main entry point for sdk-libs/macros | +| **`rentfree.md`** | `#[derive(RentFree)]` macro and trait derives | +| **`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 | + +### Traits 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 | + +## Navigation Tips + +### Starting Points + +- **Data struct traits**: Start with `traits/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 + +### Macro Hierarchy + +``` +#[rentfree_program] <- Program-level (rentfree_program/) + | + +-- Discovers #[derive(RentFree)] structs + | + +-- Generates: + - RentFreeAccountVariant enum + - Seeds structs + - Compress/Decompress instructions + - Config instructions + +#[derive(RentFree)] <- Account-level (rentfree.md) + | + +-- 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) +``` + +## Related Source Code + +``` +sdk-libs/macros/src/rentfree/ +├── 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/rentfree.md b/sdk-libs/macros/docs/rentfree.md new file mode 100644 index 0000000000..24ae6cf515 --- /dev/null +++ b/sdk-libs/macros/docs/rentfree.md @@ -0,0 +1,588 @@ +# RentFree Derive Macro and Trait Derives + +## 1. Overview + +The `#[derive(RentFree)]` macro and associated trait derives enable rent-free compressed accounts on Solana with minimal boilerplate. These macros generate code for: + +- Pre-instruction compression setup (`LightPreInit` trait) +- Post-instruction cleanup (`LightFinalize` trait) +- Account data serialization and hashing +- Pubkey compression to u8 indices + +### 1.1 Module Structure + +``` +sdk-libs/macros/src/rentfree/ +|-- mod.rs # Module exports +|-- shared_utils.rs # Common utilities (constant detection, identifier extraction) +| +|-- accounts/ # #[derive(RentFree)] for Accounts structs +| |-- mod.rs # Module entry point +| |-- derive.rs # Orchestration layer +| |-- builder.rs # Code generation builder +| |-- parse.rs # Attribute parsing with darling +| |-- pda.rs # PDA block code generation +| +-- light_mint.rs # Mint action CPI generation +| ++-- traits/ # Trait derive macros for data structs + |-- mod.rs # Module entry point + |-- traits.rs # HasCompressionInfo, Compressible, CompressAs, Size + |-- pack_unpack.rs # Pack/Unpack traits with Packed struct generation + |-- light_compressible.rs # Combined LightCompressible derive + |-- seed_extraction.rs # Anchor seed extraction from #[account(...)] + |-- decompress_context.rs # Decompression context utilities + +-- utils.rs # Shared utilities (field extraction, type checks) +``` + +--- + +## 2. `#[derive(RentFree)]` Derive Macro + +### 2.1 Purpose + +Generates `LightPreInit` and `LightFinalize` trait implementations for Anchor Accounts structs. These traits enable automatic compression of PDA accounts and mint creation during instruction execution. + +**Source**: `sdk-libs/macros/src/rentfree/accounts/derive.rs` + +### 2.2 Supported Attributes + +#### `#[rentfree]` - Mark PDA Fields for Compression + +Applied to `Account<'info, T>` or `Box>` fields. + +```rust +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct CreateAccounts<'info> { + #[account( + init, + payer = fee_payer, + space = 8 + UserRecord::INIT_SPACE, + seeds = [b"user", params.owner.as_ref()], + bump + )] + #[rentfree] // Uses default address_tree_info and output_tree from params + pub user_record: Account<'info, UserRecord>, +} +``` + +**Optional arguments**: +- `address_tree_info` - Expression of type `PackedAddressTreeInfo` containing packed tree indices (default: `params.create_accounts_proof.address_tree_info`). Note: If you have an `AddressTreeInfo` with Pubkeys, you must pack it client-side using `pack_address_tree_info()` before passing to the instruction. +- `output_tree` - Expression for output tree index (default: `params.create_accounts_proof.output_state_tree_index`) + +```rust +#[rentfree( + address_tree_info = custom_tree_info, + output_tree = custom_output_index +)] +pub user_record: Account<'info, UserRecord>, +``` + +#### `#[light_mint(...)]` - Mark Mint Fields + +Creates a compressed mint with automatic decompression. + +```rust +#[light_mint( + mint_signer = mint_signer, // AccountInfo that seeds the mint PDA (required) + authority = authority, // Mint authority (required) + decimals = 9, // Token decimals (required) + mint_seeds = &[b"mint", &[bump]], // PDA signer seeds for mint_signer (required) + freeze_authority = freeze_auth, // Optional freeze authority + authority_seeds = &[b"auth", &[auth_bump]], // PDA signer seeds for authority (optional - if not provided, authority must be a tx signer) + rent_payment = 2, // Rent payment epochs (default: 2) + write_top_up = 0 // Write top-up lamports (default: 0) +)] +pub cmint: Account<'info, CMint>, +``` + +#### `#[instruction(...)]` - Specify Instruction Parameters (Required) + +Must be present on the struct when using `#[rentfree]` or `#[light_mint]`. + +```rust +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct CreateAccounts<'info> { ... } +``` + +### 2.3 Infrastructure Field Detection + +Infrastructure fields are auto-detected by naming convention. No attribute required. + +| Field Type | Accepted Names | +|------------|----------------| +| Fee Payer | `fee_payer`, `payer`, `creator` | +| Compression Config | `compression_config` | +| 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` | + +**Source**: `sdk-libs/macros/src/rentfree/accounts/parse.rs` (lines 30-53) + +### 2.4 Code Generation Flow + +``` +1. Parse + |-- parse_rentfree_struct() extracts: + | - Struct name and generics + | - #[rentfree] fields -> RentFreeField + | - #[light_mint] fields -> LightMintField + | - #[instruction] args + | - Infrastructure fields by naming convention + | +2. Validate + |-- Total fields <= 255 (u8 index limit) + |-- #[instruction] required when #[rentfree] or #[light_mint] present + | +3. Generate pre_init Body + |-- PDAs + Mints: generate_pre_init_pdas_and_mints() + | - Write PDAs to CPI context + | - Invoke mint_action with decompress + CPI context + |-- Mints only: generate_pre_init_mints_only() + |-- PDAs only: generate_pre_init_pdas_only() + |-- Neither: Ok(false) + | +4. Wrap in Trait Impls + |-- LightPreInit<'info, ParamsType> + +-- LightFinalize<'info, ParamsType> +``` + +**Source**: `sdk-libs/macros/src/rentfree/accounts/derive.rs` + +### 2.5 Generated Code Example + +**Input**: + +```rust +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct CreateAccounts<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub compression_config: Account<'info, CompressibleConfig>, + + #[account( + init, + payer = fee_payer, + space = 8 + UserRecord::INIT_SPACE, + seeds = [b"user", params.owner.as_ref()], + bump + )] + #[rentfree] + pub user_record: Account<'info, UserRecord>, +} +``` + +**Output** (simplified): + +```rust +#[automatically_derived] +impl<'info> light_sdk::compressible::LightPreInit<'info, CreateParams> for CreateAccounts<'info> { + fn light_pre_init( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + params: &CreateParams, + ) -> std::result::Result { + use anchor_lang::ToAccountInfo; + + // Build CPI accounts + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( + &self.fee_payer, + _remaining, + crate::LIGHT_CPI_SIGNER, + ); + + // Load compression config + let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( + &self.compression_config, + &crate::ID + )?; + + // Collect compressed infos + let mut all_compressed_infos = Vec::with_capacity(1); + + // PDA 0: user_record + let __account_info_0 = self.user_record.to_account_info(); + let __account_key_0 = __account_info_0.key.to_bytes(); + let __new_addr_params_0 = { /* NewAddressParamsAssignedPacked */ }; + let __address_0 = light_compressed_account::address::derive_address(/* ... */); + let __account_data_0 = &mut *self.user_record; + let __compressed_infos_0 = light_sdk::compressible::prepare_compressed_account_on_init::(/* ... */)?; + all_compressed_infos.push(__compressed_infos_0); + + // Execute Light System Program CPI + use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; + light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( + crate::LIGHT_CPI_SIGNER, + params.create_accounts_proof.proof.clone() + ) + .with_new_addresses(&[__new_addr_params_0]) + .with_account_infos(&all_compressed_infos) + .invoke(cpi_accounts)?; + + Ok(true) + } +} + +#[automatically_derived] +impl<'info> light_sdk::compressible::LightFinalize<'info, CreateParams> for CreateAccounts<'info> { + fn light_finalize( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + params: &CreateParams, + _has_pre_init: bool, + ) -> std::result::Result<(), light_sdk::error::LightSdkError> { + use anchor_lang::ToAccountInfo; + Ok(()) + } +} +``` + +--- + +## 3. Trait Derives (traits/) + +### 3.0 Trait Composition Overview + +The following diagram shows how the derive macros compose together to enable rent-free compressed accounts: + +``` + ACCOUNT STRUCT LEVEL + ==================== + + +--------------------+ + | #[derive(RentFree)]| <-- Applied to Anchor Accounts struct + +--------------------+ + | + | generates + v + +---------------------------+ + | LightPreInit + LightFinalize | + +---------------------------+ + | + | uses traits from + v + DATA STRUCT LEVEL + ================= + ++-------------------------------------------------------------------------+ +| #[derive(LightCompressible)] | +| (convenience macro - expands to all below) | ++-------------------------------------------------------------------------+ + | | | | + | expands to | expands to | expands to | expands to + v v v v ++----------------+ +------------------+ +--------------+ +-----------------+ +| LightHasherSha | | LightDiscriminator| | Compressible | | CompressiblePack| ++----------------+ +------------------+ +--------------+ +-----------------+ + | | | | + | generates | generates | generates | generates + v v v v ++----------------+ +------------------+ +--------------+ +-----------------+ +| - DataHasher | | - LightDiscriminator| | (see below)| | - Pack | +| - ToByteArray | | (8-byte unique ID) | | | | - Unpack | ++----------------+ +------------------+ +--------------+ | - Packed{Name} | + | | struct | + v +-----------------+ + +-----------------------------+ + | Compressible | + | (combined derive macro) | + +-----------------------------+ + | | | | + v v v v + +------------------+ +------------------+ + | HasCompressionInfo| | CompressAs | + +------------------+ +------------------+ + | - compression_info()| | - compress_as() | + | - compression_info_mut()| Creates compressed | + | - set_compression_info_none()| representation| + +------------------+ +------------------+ + | | + v v + +------------------+ +------------------+ + | Size | | CompressedInitSpace| + +------------------+ +------------------+ + | - size() | | - INIT_SPACE | + | Serialized size | | Compressed account| + +------------------+ +------------------+ + + + RELATIONSHIP SUMMARY + ==================== + + +-------------------------------------------------------------------+ + | USER'S PROGRAM CODE | + +-------------------------------------------------------------------+ + | | + | // Data struct - apply LightCompressible | + | #[derive(LightCompressible)] | + | #[account] | + | pub struct UserRecord { | + | pub owner: Pubkey, | + | pub score: u64, | + | pub compression_info: Option, <-- Required | + | } | + | | + | // Accounts struct - apply RentFree | + | #[derive(Accounts, RentFree)] | + | #[instruction(params: CreateParams)] | + | pub struct Create<'info> { | + | #[account(init, ...)] | + | #[rentfree] <-- Marks for compression | + | pub user_record: Account<'info, UserRecord>, | + | } | + | | + +-------------------------------------------------------------------+ + | + | At runtime, RentFree uses traits from + | LightCompressible to: + v + +-------------------------------------------------------------------+ + | 1. Hash account data (DataHasher, ToByteArray) | + | 2. Get discriminator (LightDiscriminator) | + | 3. Create compressed representation (CompressAs) | + | 4. Calculate sizes (Size, CompressedInitSpace) | + | 5. Pack Pubkeys to indices (Pack, Unpack) | + | 6. Access compression info (HasCompressionInfo) | + +-------------------------------------------------------------------+ +``` + +### 3.1 HasCompressionInfo + +Provides accessors for the `compression_info` field. + +**Source**: `sdk-libs/macros/src/rentfree/traits/traits.rs` (lines 69-88) + +**Requirements**: Struct must have `compression_info: Option` field. + +**Generated methods**: +- `compression_info(&self) -> &CompressionInfo` +- `compression_info_mut(&mut self) -> &mut CompressionInfo` +- `compression_info_mut_opt(&mut self) -> &mut Option` +- `set_compression_info_none(&mut self)` + +### 3.2 Compressible + +Combined derive that generates: +- `HasCompressionInfo` - Accessor for compression_info field +- `CompressAs` - Creates compressed representation +- `Size` - Calculates serialized size +- `CompressedInitSpace` - INIT_SPACE for compressed accounts + +**Source**: `sdk-libs/macros/src/rentfree/traits/traits.rs` (lines 233-272) + +**Optional attribute** `#[compress_as(field = expr, ...)]`: +- Override field values in compressed representation +- Useful for zeroing out fields that shouldn't be hashed + +```rust +#[derive(Compressible)] +#[compress_as(start_time = 0, cached_value = 0)] +pub struct GameSession { + pub session_id: u64, + pub player: Pubkey, + pub start_time: u64, // Will be 0 in compressed form + pub cached_value: u64, // Will be 0 in compressed form + pub compression_info: Option, +} +``` + +**Auto-skipped fields**: +- `compression_info` (always handled specially) +- Fields with `#[skip]` attribute + +#### `#[skip]` - Exclude Fields from Compression + +Mark fields to exclude from `CompressAs` and `Size` calculations: + +```rust +#[derive(Compressible)] +pub struct CachedData { + pub id: u64, + #[skip] // Not included in compressed representation + pub cached_timestamp: u64, + pub compression_info: Option, +} +``` + +### 3.3 Pack/Unpack (CompressiblePack) + +Generates `Pack` and `Unpack` traits with a `Packed{StructName}` struct where Pubkey fields are compressed to u8 indices. + +**Source**: `sdk-libs/macros/src/rentfree/traits/pack_unpack.rs` + +**Input**: +```rust +#[derive(CompressiblePack)] +pub struct UserRecord { + pub owner: Pubkey, + pub authority: Pubkey, + pub score: u64, + pub compression_info: Option, +} +``` + +**Generated**: +```rust +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct PackedUserRecord { + pub owner: u8, // Pubkey -> u8 index + pub authority: u8, // Pubkey -> u8 index + pub score: u64, // Non-Pubkey unchanged + pub compression_info: Option, +} + +impl Pack for UserRecord { + type Packed = PackedUserRecord; + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed { + PackedUserRecord { + owner: remaining_accounts.insert_or_get(self.owner), + authority: remaining_accounts.insert_or_get(self.authority), + score: self.score, + compression_info: None, + } + } +} + +impl Unpack for PackedUserRecord { + type Unpacked = UserRecord; + fn unpack(&self, remaining_accounts: &[AccountInfo]) -> Result { + Ok(UserRecord { + owner: *remaining_accounts[self.owner as usize].key, + authority: *remaining_accounts[self.authority as usize].key, + score: self.score, + compression_info: None, + }) + } +} +``` + +**No Pubkey fields**: If struct has no Pubkey fields, generates identity implementations: +```rust +pub type PackedUserRecord = UserRecord; // Type alias +// Pack::pack returns self.clone() +// Unpack::unpack returns self.clone() +``` + +### 3.4 LightCompressible + +Convenience derive that combines all traits needed for a compressible account. + +**Source**: `sdk-libs/macros/src/rentfree/traits/light_compressible.rs` + +**Equivalent to**: +```rust +#[derive(LightHasherSha, LightDiscriminator, Compressible, CompressiblePack)] +``` + +**Generated traits**: +- `DataHasher` + `ToByteArray` (SHA256 hashing via LightHasherSha) +- `LightDiscriminator` (unique 8-byte discriminator) +- `HasCompressionInfo` + `CompressAs` + `Size` + `CompressedInitSpace` (via Compressible) +- `Pack` + `Unpack` + `Packed{Name}` struct (via CompressiblePack) + +**Usage**: +```rust +#[derive(Default, Debug, InitSpace, LightCompressible)] +#[account] +pub struct UserRecord { + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub score: u64, + pub compression_info: Option, +} +``` + +**Notes**: +- `compression_info` field is auto-detected and handled specially (no `#[skip]` needed) +- SHA256 hashes the entire struct, so no `#[hash]` attributes needed + +--- + +## 4. Source Code Structure + +``` +sdk-libs/macros/src/rentfree/ +| +|-- mod.rs +| Purpose: Module exports for rentfree macro system +| +|-- shared_utils.rs +| Purpose: Common utilities shared across modules +| Types: +| - MetaExpr - darling wrapper for parsing Expr from attributes +| Functions: +| - qualify_type_with_crate(ty: &Type) -> Type - ensures crate:: prefix +| - make_packed_type(ty: &Type) -> Option - creates Packed{Type} path +| - make_packed_variant_name(variant_name: &Ident) -> Ident +| - ident_to_type(ident: &Ident) -> Type +| - is_constant_identifier(ident: &str) -> bool +| - extract_terminal_ident(expr: &Expr, key_method_only: bool) -> Option +| - is_base_path(expr: &Expr, base: &str) -> bool +| +|-- accounts/ +| |-- mod.rs Entry point, exports derive_rentfree() +| |-- derive.rs Orchestration: parse -> validate -> generate +| |-- builder.rs RentFreeBuilder for code generation +| |-- parse.rs Attribute parsing with darling +| | - ParsedRentFreeStruct +| | - RentFreeField (#[rentfree] data) +| | - InfraFields (auto-detected infrastructure) +| | - InfraFieldClassifier (naming convention matching) +| |-- pda.rs PDA compression block generation +| | - PdaBlockBuilder +| | - generate_pda_compress_blocks() +| +-- light_mint.rs Mint action CPI generation +| - LightMintField (#[light_mint] data) +| - InfraRefs - resolved infrastructure field references +| - LightMintBuilder - builder pattern for mint CPI generation +| - CpiContextParts - encapsulates CPI context branching logic +| ++-- traits/ + |-- mod.rs Entry point for trait derives + |-- traits.rs Core traits + | - derive_has_compression_info() + | - derive_compress_as() + | - derive_compressible() [combined] + |-- pack_unpack.rs Pack/Unpack trait generation + | - derive_compressible_pack() + |-- light_compressible.rs Combined derive + | - derive_rentfree_account() [LightCompressible] + |-- seed_extraction.rs Anchor seed parsing + | - ClassifiedSeed enum + | - ExtractedSeedSpec, ExtractedTokenSpec + | - extract_anchor_seeds() + | - extract_account_inner_type() + |-- decompress_context.rs Decompression utilities + +-- utils.rs Shared utilities + - extract_fields_from_derive_input() + - is_copy_type(), is_pubkey_type() +``` + +--- + +## 5. Limitations + +### Field Limits +- **Maximum 255 fields**: Total `#[rentfree]` + `#[light_mint]` fields must be <= 255 (u8 index limit) +- **Single mint field**: Currently only the first `#[light_mint]` field is processed + +### Type Restrictions +- `#[rentfree]` only applies to `Account<'info, T>` or `Box>` fields +- Nested `Box>>` is not supported +- `#[rentfree]` and `#[light_mint]` are mutually exclusive on the same field + +### No-op Fallback +When no `#[instruction]` attribute is present, the macro generates no-op implementations for backwards compatibility with non-compressible Accounts structs. + +--- + +## 6. Related Documentation + +- **`sdk-libs/macros/docs/rentfree_program/`** - Program-level `#[rentfree_program]` attribute macro (architecture.md + codegen.md) +- **`sdk-libs/macros/README.md`** - Package overview +- **`sdk-libs/sdk/`** - Runtime SDK with `LightPreInit`, `LightFinalize` trait definitions diff --git a/sdk-libs/macros/docs/rentfree_program/architecture.md b/sdk-libs/macros/docs/rentfree_program/architecture.md new file mode 100644 index 0000000000..9bed62d7e3 --- /dev/null +++ b/sdk-libs/macros/docs/rentfree_program/architecture.md @@ -0,0 +1,206 @@ +# `#[rentfree_program]` Attribute Macro + +## 1. Overview + +The `#[rentfree_program]` attribute macro provides program-level auto-discovery and instruction wrapping for Light Protocol's rent-free compression system. It eliminates boilerplate by automatically generating compression infrastructure from your existing Anchor code. + +**Location**: `sdk-libs/macros/src/rentfree/program/` + +## 2. Required Macros + +| Location | Macro | Purpose | +|----------|-------|---------| +| Program module | `#[rentfree_program]` | Discovers fields, generates instructions, wraps handlers | +| Accounts struct | `#[derive(RentFree)]` | Generates `LightPreInit`/`LightFinalize` trait impls | +| Account field | `#[rentfree]` | Marks PDA for compression | +| Account field | `#[rentfree_token(authority=[...])]` | Marks token account for compression | +| State struct | `#[derive(LightCompressible)]` | Generates compression traits + `Packed{Type}` | +| State struct | `compression_info: Option` | Required field for compression metadata | + +## 3. How It Works + +### 3.1 High-Level Flow + +``` ++------------------+ +------------------+ +------------------+ +| User Code | --> | Macro at | --> | Generated | +| | | Compile Time | | Code | ++------------------+ +------------------+ +------------------+ +| - Program module | | 1. Parse crate | | - Variant enums | +| - Accounts | | 2. Find #[rent- | | - Seeds structs | +| structs | | free] fields | | - Compress/ | +| - State structs | | 3. Extract seeds | | Decompress ix | +| | | 4. Generate code | | - Wrapped fns | ++------------------+ +------------------+ +------------------+ +``` + +### 3.2 Compile-Time Discovery + +The macro reads your crate at compile time to find compressible accounts: + +``` +#[rentfree_program] +#[program] +pub mod my_program { + pub mod accounts; <-- Macro follows this to accounts.rs + pub mod state; <-- And this to state.rs + ... +} + + | + v + ++----------------------------------------------------------+ +| DISCOVERY | ++----------------------------------------------------------+ +| | +| For each #[derive(Accounts)] struct: | +| | +| 1. Find #[rentfree] fields --> PDA accounts | +| 2. Find #[rentfree_token] fields --> Token accounts | +| 3. Parse #[account(seeds=[...])] --> Seed expressions | +| 4. Parse #[instruction(...)] --> Params type | +| | ++----------------------------------------------------------+ +``` + +### 3.3 Seed Classification + +Seeds from `#[account(seeds = [...])]` are classified by source: + +``` ++----------------------+---------------------------+------------------------+ +| Seed Expression | Classification | Used For | ++----------------------+---------------------------+------------------------+ +| b"literal" | Static bytes | PDA derivation | +| CONSTANT | crate::CONSTANT ref | PDA derivation | +| authority.key() | Context account (Pubkey) | Variant enum field | +| params.owner | Instruction data field | Seeds struct + verify | ++----------------------+---------------------------+------------------------+ +``` + +Context account seeds become fields in the variant enum. Instruction data seeds become fields in the Seeds struct and are verified against account data. + +### 3.4 Code Generation + +``` + GENERATED ARTIFACTS ++------------------------------------------------------------------+ +| | +| RentFreeAccountVariant TokenAccountVariant | +| +------------------------+ +------------------------+ | +| | UserRecord { data, .. }| | Vault { mint } | | +| | PackedUserRecord {...} | | PackedVault { mint_idx}| | +| +------------------------+ +------------------------+ | +| | | | +| v v | +| UserRecordSeeds get_vault_seeds() | +| UserRecordCtxSeeds get_vault_authority_seeds() | +| | ++------------------------------------------------------------------+ +| | +| INSTRUCTIONS | +| +--------------------+ +--------------------+ +--------------+| +| | decompress_ | | compress_ | | init/update_ || +| | accounts_ | | accounts_ | | compression_ || +| | idempotent | | idempotent | | config || +| +--------------------+ +--------------------+ +--------------+| +| | ++------------------------------------------------------------------+ +``` + +### 3.5 Instruction Wrapping + +Original instruction handlers are automatically wrapped with lifecycle hooks: + +``` +ORIGINAL WRAPPED (generated) ++---------------------------+ +----------------------------------+ +| pub fn create_user( | | pub fn create_user( | +| ctx: Context, | -> | ctx: Context, | +| params: Params | | params: Params | +| ) -> Result<()> { | | ) -> Result<()> { | +| // business logic | | // 1. light_pre_init | +| } | | // 2. business logic (closure) | ++---------------------------+ | // 3. light_finalize | + | } | + +----------------------------------+ +``` + +### 3.6 Runtime Flows + +**Create (Compression)** +``` +User calls create_user + | + v +light_pre_init: Register address in Merkle tree + | + v +Business logic: Set account fields + | + v +light_finalize: Complete compression via CPI + | + v +Account exists as compressed state + temporary PDA +``` + +**Decompress (Read/Modify)** +``` +Client fetches compressed account from indexer + | + v +Client calls decompress_accounts_idempotent + | + v +PDA recreated on-chain from compressed state + | + v +User interacts with standard Anchor account +``` + +**Re-Compress (Return to compressed)** +``` +Authority calls compress_accounts_idempotent + | + v +PDA closed, state written to Merkle tree + | + v +Rent returned to sponsor +``` + +## 4. Generated Items Summary + +| Item | Purpose | +|------|---------| +| `RentFreeAccountVariant` | Unified enum for all compressible account types (packed + unpacked) | +| `TokenAccountVariant` | Enum for token account types | +| `{Type}Seeds` | Client-side PDA derivation with seed values | +| `{Type}CtxSeeds` | Decompression context with resolved Pubkeys | +| `decompress_accounts_idempotent` | Recreate PDAs from compressed state | +| `compress_accounts_idempotent` | Compress PDAs back to Merkle tree | +| `initialize_compression_config` | Setup compression config PDA | +| `update_compression_config` | Modify compression config | +| `get_{type}_seeds()` | Client helper functions for PDA derivation | +| `RentFreeInstructionError` | Error codes for compression operations | + +## 5. Seed Expression Support + +Seeds in `#[account(seeds = [...])]` can reference: + +- **Literals**: `b"seed"` or `"seed"` +- **Constants**: `MY_SEED` (resolved as `crate::MY_SEED`) +- **Context accounts**: `authority.key().as_ref()` +- **Instruction data**: `params.owner.as_ref()` or `params.id.to_le_bytes().as_ref()` +- **Function calls**: `max_key(&a.key(), &b.key()).as_ref()` + +## 6. Limitations + +| Limitation | Details | +|------------|---------| +| Max size | 800 bytes per compressed account (compile-time check) | +| Module discovery | Requires `pub mod name;` pattern (not inline `mod name {}`) | +| Instruction variants | Only `Mixed` (PDA + token) fully implemented | +| Token authority | `#[rentfree_token]` requires `authority = [...]` seeds | diff --git a/sdk-libs/macros/docs/rentfree_program/codegen.md b/sdk-libs/macros/docs/rentfree_program/codegen.md new file mode 100644 index 0000000000..b7e782e9f6 --- /dev/null +++ b/sdk-libs/macros/docs/rentfree_program/codegen.md @@ -0,0 +1,165 @@ +# `#[rentfree_program]` Code Generation + +Technical implementation details for the `#[rentfree_program]` attribute macro. + +## 1. Source Code Structure + +``` +sdk-libs/macros/src/rentfree/program/ +|-- mod.rs # Module exports, main entry point rentfree_program_impl +|-- instructions.rs # Main orchestration: codegen(), rentfree_program_impl() +|-- parsing.rs # Core types (TokenSeedSpec, SeedElement, InstructionDataSpec) +| # Expression analysis, seed conversion, function wrapping +|-- compress.rs # CompressAccountsIdempotent generation +| # CompressContext trait impl, compress processor +|-- decompress.rs # DecompressAccountsIdempotent generation +| # DecompressContext trait impl, PDA seed provider impls +|-- variant_enum.rs # RentFreeAccountVariant enum generation +| # TokenAccountVariant/PackedTokenAccountVariant generation +| # Pack/Unpack trait implementations +|-- seed_codegen.rs # Client seed function generation +| # TokenSeedProvider implementation generation +|-- crate_context.rs # Anchor-style crate parsing (CrateContext, ParsedModule) +| # Module file discovery and parsing +|-- expr_traversal.rs # AST expression transformation (ctx.field -> ctx_seeds.field) +|-- seed_utils.rs # Seed expression conversion utilities +| # SeedConversionConfig, seed_element_to_ref_expr() +|-- visitors.rs # Visitor-based AST traversal (FieldExtractor) +| # ClientSeedInfo classification and code generation +``` + +### Related Files + +``` +sdk-libs/macros/src/rentfree/ +|-- traits/ +| |-- seed_extraction.rs # ClassifiedSeed enum, Anchor seed parsing +| | # extract_from_accounts_struct() +| |-- decompress_context.rs # DecompressContext trait impl generation +| |-- utils.rs # Shared utilities (is_pubkey_type, etc.) +|-- shared_utils.rs # Cross-module utilities (is_constant_identifier, etc.) +``` + + +## 2. Code Generation Flow + +``` + #[rentfree_program] + | + v + +-----------------------------+ + | rentfree_program_impl() | + | (instructions.rs:405) | + +-----------------------------+ + | + +-----------------+-----------------+ + | | + v v ++------------------+ +----------------------+ +| CrateContext | | extract_context_and_ | +| ::parse_from_ | | params() + wrap_ | +| manifest() | | function_with_ | +| (crate_context.rs)| | rentfree() | ++------------------+ | (parsing.rs) | + | +----------------------+ + v | ++------------------+ | +| structs_with_ | | +| derive("Accounts")| | ++------------------+ | + | | + v | ++------------------------+ | +| extract_from_accounts_ | | +| struct() | | +| (seed_extraction.rs) | | ++------------------------+ | + | | + v v ++--------------------------------------------------+ +| codegen() | +| (instructions.rs:38) | ++--------------------------------------------------+ + | + +---> validate_compressed_account_sizes() + | (compress.rs) + | + +---> compressed_account_variant_with_ctx_seeds() + | (variant_enum.rs) + | + +---> generate_ctoken_account_variant_enum() + | (variant_enum.rs) + | + +---> generate_decompress_*() + | (decompress.rs) + | + +---> generate_compress_*() + | (compress.rs) + | + +---> generate_pda_seed_provider_impls() + | (decompress.rs) + | + +---> generate_ctoken_seed_provider_implementation() + | (seed_codegen.rs) + | + +---> generate_client_seed_functions() + (seed_codegen.rs) +``` + + +## 3. Key Implementation Details + +### Automatic Function Wrapping + +Functions using `#[rentfree]` Accounts structs are automatically wrapped with lifecycle hooks: + +```rust +// Original: +pub fn create_user(ctx: Context, params: Params) -> Result<()> { + ctx.accounts.user.owner = params.owner; + Ok(()) +} + +// Wrapped (generated): +pub fn create_user(ctx: Context, params: Params) -> Result<()> { + use light_sdk::compressible::{LightPreInit, LightFinalize}; + + // Phase 1: Pre-init (registers compressed addresses) + let __has_pre_init = ctx.accounts.light_pre_init(ctx.remaining_accounts, ¶ms)?; + + // Execute original handler + let __light_handler_result = (|| { + ctx.accounts.user.owner = params.owner; + Ok(()) + })(); + + // Phase 2: Finalize compression on success + if __light_handler_result.is_ok() { + ctx.accounts.light_finalize(ctx.remaining_accounts, ¶ms, __has_pre_init)?; + } + + __light_handler_result +} +``` + +### Size Validation + +Compressed accounts are validated at compile time to not exceed 800 bytes: + +```rust +const _: () = { + const COMPRESSED_SIZE: usize = 8 + ::COMPRESSED_INIT_SPACE; + if COMPRESSED_SIZE > 800 { + panic!("Compressed account 'UserRecord' exceeds 800-byte compressible account size limit."); + } +}; +``` + +### Instruction Variants + +The macro supports three instruction variants based on field types: +- `PdaOnly`: Only `#[rentfree]` PDA fields +- `TokenOnly`: Only `#[rentfree_token]` token fields +- `Mixed`: Both PDA and token fields (most common) + +Currently, only `Mixed` variant is fully implemented. `PdaOnly` and `TokenOnly` will error at runtime. diff --git a/sdk-libs/macros/docs/traits/compress_as.md b/sdk-libs/macros/docs/traits/compress_as.md new file mode 100644 index 0000000000..45bde94e2f --- /dev/null +++ b/sdk-libs/macros/docs/traits/compress_as.md @@ -0,0 +1,223 @@ +# CompressAs Derive Macro + +## 1. Overview + +The `#[derive(CompressAs)]` macro generates the `CompressAs` trait implementation, which creates a compressed representation of an account struct. This compressed form is used for hashing and storing in the Light Protocol compression system. + +**When to use**: Apply this derive when you need only the compression transformation logic. For most use cases, prefer `#[derive(Compressible)]` or `#[derive(LightCompressible)]` which include this trait. + +**Source**: `sdk-libs/macros/src/rentfree/traits/traits.rs` (lines 91-153) + +--- + +## 2. How It Works + +### 2.1 Compile-Time Flow + +``` ++---------------------+ +-------------------+ +-------------------+ +| Input Struct | --> | Macro at | --> | Generated | +| | | Compile Time | | Code | ++---------------------+ +-------------------+ +-------------------+ +| #[compress_as( | | 1. Parse struct | | impl CompressAs | +| cached = 0)] | | attributes | | for GameData { | +| pub struct GameData | | 2. Classify each | | fn compress_as | +| { | | field: | | -> Cow | +| score: u64, | | - Skip? | | { ... } | +| cached: u64, | | - Override? | | } | +| compression_info | | - Copy/Clone? | | | +| } | | 3. Generate impl | | | ++---------------------+ +-------------------+ +-------------------+ +``` + +### 2.2 Field Classification + +Each struct field is classified at compile time: + +``` +Field Processing Pipeline ++------------------------+ +| Input Field | ++------------------------+ + | + v ++------------------------+ YES +------------------+ +| Is "compression_info"? |------------>| Set to None | ++------------------------+ +------------------+ + | NO + v ++------------------------+ YES +------------------+ +| Has #[skip] attr? |------------>| Exclude entirely | ++------------------------+ +------------------+ + | NO + v ++------------------------+ YES +------------------+ +| Has #[compress_as] |------------>| Use override | +| override? | | expression | ++------------------------+ +------------------+ + | NO + v ++------------------------+ YES +------------------+ +| Is Copy type? |------------>| self.field | ++------------------------+ +------------------+ + | NO + v ++------------------------+ +| self.field.clone() | ++------------------------+ +``` + +### 2.3 Purpose in Compression System + +The compressed representation is used for hashing account state: + +``` +Original Account compress_as() Hash Input ++----------------------+ +----------------------+ +----------+ +| score: 100 | | score: 100 | | | +| cached: 999 | --> | cached: 0 (zeroed) | -> | SHA256 | +| last_login: 12345 | | (skipped) | | hash | +| compression_info: | | compression_info: | | | +| Some(...) | | None | | | ++----------------------+ +----------------------+ +----------+ +``` + +This ensures that: +- Transient fields (caches, timestamps) don't affect the hash +- `compression_info` metadata doesn't affect content hash +- Only semantically meaningful data is included + +--- + +## 3. Generated Trait + +The macro implements `light_sdk::compressible::CompressAs`: + +```rust +impl CompressAs for YourStruct { + type Output = Self; + + fn compress_as(&self) -> Cow<'_, Self::Output>; +} +``` + +The `compress_as()` method returns a `Cow::Owned` containing a copy of the struct with: +- `compression_info` set to `None` +- All other fields copied (Clone for non-Copy types) +- Any `#[compress_as(...)]` overrides applied + +--- + +## 4. Supported Attributes + +### `#[compress_as(field = expr, ...)]` - Field Overrides + +Override specific field values in the compressed representation. Useful for zeroing out fields that shouldn't affect the compressed hash. + +```rust +#[derive(CompressAs)] +#[compress_as(start_time = 0, cached_value = 0)] +pub struct GameSession { + pub session_id: u64, + pub player: Pubkey, + pub start_time: u64, // Will be 0 in compressed form + pub cached_value: u64, // Will be 0 in compressed form + pub compression_info: Option, +} +``` + +### `#[skip]` - Exclude Fields + +Mark fields to exclude from the compressed representation entirely: + +```rust +#[derive(CompressAs)] +pub struct CachedData { + pub id: u64, + #[skip] // Not included in compress_as output + pub cached_timestamp: u64, + pub compression_info: Option, +} +``` + +--- + +## 5. Auto-Skipped Fields + +The following fields are automatically excluded from compression: +- `compression_info` - Always handled specially (set to `None`) +- Fields marked with `#[skip]` + +--- + +## 6. Code Example + +### Input + +```rust +use light_sdk::compressible::CompressionInfo; +use light_sdk_macros::CompressAs; + +#[derive(Clone, CompressAs)] +#[compress_as(cached_score = 0)] +pub struct UserRecord { + pub owner: Pubkey, + pub score: u64, + pub cached_score: u64, // Overridden to 0 + #[skip] + pub last_updated: u64, // Excluded entirely + pub compression_info: Option, +} +``` + +### Generated Output + +```rust +impl light_sdk::compressible::CompressAs for UserRecord { + type Output = Self; + + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + std::borrow::Cow::Owned(Self { + compression_info: None, + owner: self.owner, // Copy type - direct copy + score: self.score, // Copy type - direct copy + cached_score: 0, // Override from #[compress_as] + // last_updated skipped due to #[skip] + }) + } +} +``` + +--- + +## 7. Copy vs Clone Behavior + +The macro automatically detects Copy types and handles them efficiently: + +| Type | Behavior | +|------|----------| +| Copy types (`u8`, `u64`, `Pubkey`, etc.) | Direct copy: `self.field` | +| Non-Copy types (`String`, `Vec`, etc.) | Clone: `self.field.clone()` | + +Copy types recognized: +- Primitives: `bool`, `u8`-`u128`, `i8`-`i128`, `f32`, `f64`, `char` +- Solana types: `Pubkey` +- Arrays of Copy types + +--- + +## 8. Usage Notes + +- The struct must implement `Clone` for non-Copy field types +- Field overrides in `#[compress_as(...)]` must be valid expressions for the field type +- The `compression_info` field is required but does not need to be specified in overrides + +--- + +## 9. Related Macros + +| Macro | Relationship | +|-------|--------------| +| [`HasCompressionInfo`](has_compression_info.md) | Provides compression info accessors (used alongside) | +| [`Compressible`](compressible.md) | Includes `CompressAs` + other compression traits | +| [`LightCompressible`](light_compressible.md) | Includes all compression traits including `CompressAs` | diff --git a/sdk-libs/macros/docs/traits/compressible.md b/sdk-libs/macros/docs/traits/compressible.md new file mode 100644 index 0000000000..c2ee2a1d3c --- /dev/null +++ b/sdk-libs/macros/docs/traits/compressible.md @@ -0,0 +1,296 @@ +# Compressible Derive Macro + +## 1. Overview + +The `#[derive(Compressible)]` macro is a combined derive that generates all core compression traits needed for an account struct. It is the recommended way to add compression support when you don't need hashing or discriminator traits. + +**When to use**: Apply this derive when you need compression traits but are handling hashing and discriminator separately. For full compression support, use `#[derive(LightCompressible)]` instead. + +**Source**: `sdk-libs/macros/src/rentfree/traits/traits.rs` (lines 233-272) + +--- + +## 2. How It Works + +### 2.1 Compile-Time Expansion + +``` ++------------------+ +--------------------+ +--------------------+ +| Input Struct | --> | Compressible | --> | 4 Trait Impls | +| | | Macro | | | ++------------------+ +--------------------+ +--------------------+ +| #[derive( | | Expands to 4 | | - HasCompression- | +| Compressible)] | | internal derives: | | Info | +| pub struct User {| | | | - CompressAs | +| owner: Pubkey, | | 1. HasCompression- | | - Size | +| score: u64, | | Info | | - CompressedInit- | +| compression_ | | 2. CompressAs | | Space | +| info: ... | | 3. Size | | | +| } | | 4. CompressedInit- | | | +| | | Space | | | ++------------------+ +--------------------+ +--------------------+ +``` + +### 2.2 Trait Generation Pipeline + +``` +derive_compressible() + | + +---> validate_compression_info_field() + | | + | v + | Error if missing compression_info field + | + +---> generate_has_compression_info_impl() + | | + | v + | HasCompressionInfo trait impl + | + +---> generate_compress_as_field_assignments() + | | + | +---> Process each field + | | - Skip compression_info + | | - Skip #[skip] fields + | | - Apply #[compress_as] overrides + | | - Copy vs Clone detection + | v + | generate_compress_as_impl() + | + +---> generate_size_fields() + | | + | v + | Size trait impl + | + +---> generate_compressed_init_space_impl() + | + v + CompressedInitSpace trait impl +``` + +### 2.3 Role in Compression System + +The four traits work together during compression/decompression: + +``` +COMPRESSION FLOW ++------------------------+ +| Account Data | ++------------------------+ + | + | HasCompressionInfo + v ++------------------------+ +| Set compression_info | +| with address, lamports | ++------------------------+ + | + | CompressAs + v ++------------------------+ +| Create clean copy for | +| hashing (no metadata) | ++------------------------+ + | + | Size + v ++------------------------+ +| Calculate byte size | +| for Merkle tree leaf | ++------------------------+ + | + | CompressedInitSpace + v ++------------------------+ +| Verify fits in 800 | +| byte limit | ++------------------------+ +``` + +--- + +## 3. Generated Traits + +The `Compressible` derive generates implementations for four traits: + +| Trait | Purpose | +|-------|---------| +| `HasCompressionInfo` | Accessor methods for `compression_info` field | +| `CompressAs` | Creates compressed representation for hashing | +| `Size` | Calculates serialized byte size | +| `CompressedInitSpace` | Provides `COMPRESSED_INIT_SPACE` constant | + +### Equivalent Manual Derives + +```rust +// This: +#[derive(Compressible)] +pub struct MyAccount { ... } + +// Is equivalent to: +#[derive(HasCompressionInfo, CompressAs, Size)] // + CompressedInitSpace +pub struct MyAccount { ... } +``` + +--- + +## 4. Required Field + +The struct **must** have a field named `compression_info` of type `Option`: + +```rust +pub struct MyAccount { + pub data: u64, + pub compression_info: Option, // Required +} +``` + +--- + +## 5. Supported Attributes + +### `#[compress_as(field = expr, ...)]` - Field Overrides + +Override specific field values in the compressed representation: + +```rust +#[derive(Compressible)] +#[compress_as(start_time = 0, cached_value = 0)] +pub struct GameSession { + pub session_id: u64, + pub player: Pubkey, + pub start_time: u64, // Will be 0 in compressed form + pub cached_value: u64, // Will be 0 in compressed form + pub compression_info: Option, +} +``` + +### `#[skip]` - Exclude Fields + +Mark fields to exclude from both `CompressAs` output and `Size` calculation: + +```rust +#[derive(Compressible)] +pub struct CachedData { + pub id: u64, + #[skip] // Excluded from compression and size + pub cached_timestamp: u64, + pub compression_info: Option, +} +``` + +--- + +## 6. Generated Code Example + +### Input + +```rust +use anchor_lang::prelude::*; +use light_sdk::compressible::CompressionInfo; +use light_sdk_macros::Compressible; + +#[derive(Clone, InitSpace, Compressible)] +#[compress_as(cached_score = 0)] +pub struct UserRecord { + pub owner: Pubkey, + pub score: u64, + pub cached_score: u64, + pub compression_info: Option, +} +``` + +### Generated Output + +```rust +// HasCompressionInfo implementation +impl light_sdk::compressible::HasCompressionInfo for UserRecord { + fn compression_info(&self) -> &light_sdk::compressible::CompressionInfo { + self.compression_info.as_ref().expect("compression_info must be set") + } + + fn compression_info_mut(&mut self) -> &mut light_sdk::compressible::CompressionInfo { + self.compression_info.as_mut().expect("compression_info must be set") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } +} + +// CompressAs implementation +impl light_sdk::compressible::CompressAs for UserRecord { + type Output = Self; + + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + std::borrow::Cow::Owned(Self { + compression_info: None, + owner: self.owner, + score: self.score, + cached_score: 0, // Override applied + }) + } +} + +// Size implementation +impl light_sdk::account::Size for UserRecord { + fn size(&self) -> usize { + // CompressionInfo space: 1 (Option discriminant) + INIT_SPACE + let compression_info_size = 1 + ::INIT_SPACE; + compression_info_size + + self.owner.try_to_vec().expect("Failed to serialize").len() + + self.score.try_to_vec().expect("Failed to serialize").len() + + self.cached_score.try_to_vec().expect("Failed to serialize").len() + } +} + +// CompressedInitSpace implementation +impl light_sdk::compressible::CompressedInitSpace for UserRecord { + const COMPRESSED_INIT_SPACE: usize = Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE; +} +``` + +--- + +## 7. Size Calculation + +The `Size` trait calculates the serialized byte size of the account: + +- **CompressionInfo space**: Always allocates space for `Some(CompressionInfo)` since it will be set during decompression +- **Field serialization**: Uses `try_to_vec()` (Borsh serialization) for accurate size +- **Auto-skipped fields**: `compression_info` and `#[skip]` fields are excluded + +--- + +## 8. CompressedInitSpace Calculation + +The `CompressedInitSpace` trait provides: + +```rust +const COMPRESSED_INIT_SPACE: usize = Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE; +``` + +This requires the struct to also derive `LightDiscriminator` and Anchor's `InitSpace`. + +--- + +## 9. Usage Notes + +- The struct must derive `Clone` if it has non-Copy fields +- The struct should derive Anchor's `InitSpace` for `COMPRESSED_INIT_SPACE` to work +- For full compression support including hashing, use `#[derive(LightCompressible)]` + +--- + +## 10. Related Macros + +| Macro | Relationship | +|-------|--------------| +| [`HasCompressionInfo`](has_compression_info.md) | Included in `Compressible` | +| [`CompressAs`](compress_as.md) | Included in `Compressible` | +| [`CompressiblePack`](compressible_pack.md) | Pack/Unpack for Pubkey compression (separate derive) | +| [`LightCompressible`](light_compressible.md) | Includes `Compressible` + hashing + discriminator + pack | diff --git a/sdk-libs/macros/docs/traits/compressible_pack.md b/sdk-libs/macros/docs/traits/compressible_pack.md new file mode 100644 index 0000000000..e573ef8e2c --- /dev/null +++ b/sdk-libs/macros/docs/traits/compressible_pack.md @@ -0,0 +1,331 @@ +# CompressiblePack Derive Macro + +## 1. Overview + +The `#[derive(CompressiblePack)]` macro generates `Pack` and `Unpack` trait implementations along with a `Packed{StructName}` struct. This enables efficient Pubkey compression where 32-byte Pubkeys are replaced with u8 indices into a remaining accounts array. + +**When to use**: Apply this derive when you need to pack account data for compressed account instructions. This is automatically included in `#[derive(LightCompressible)]`. + +**Source**: `sdk-libs/macros/src/rentfree/traits/pack_unpack.rs` (lines 8-186) + +--- + +## 2. How It Works + +### 2.1 Compile-Time Decision + +``` +derive_compressible_pack() + | + v ++-------------------------+ +| Scan struct fields for | +| Pubkey types | ++-------------------------+ + | + +-----+-----+ + | | + v v ++-------+ +---------+ +| Has | | No | +| Pubkey| | Pubkey | ++-------+ +---------+ + | | + v v ++---------------+ +------------------+ +| Generate full | | Generate type | +| Packed struct | | alias + identity | +| + conversions | | impls | ++---------------+ +------------------+ +``` + +### 2.2 Pubkey Compression Flow + +32-byte Pubkeys are compressed to 1-byte indices: + +``` +PACK (Client-side) ++---------------------------+ +---------------------------+ +| UserRecord | | PackedUserRecord | ++---------------------------+ +---------------------------+ +| owner: ABC123... | -> | owner: 0 | +| authority: DEF456... | -> | authority: 1 | +| score: 100 | -> | score: 100 | ++---------------------------+ +---------------------------+ + | + v + +------------------+ + | remaining_accounts| + +------------------+ + | [0] ABC123... | + | [1] DEF456... | + +------------------+ + +UNPACK (On-chain) ++---------------------------+ +---------------------------+ +| PackedUserRecord | | UserRecord | ++---------------------------+ +---------------------------+ +| owner: 0 | -> | owner: ABC123... | +| authority: 1 | -> | authority: DEF456... | +| score: 100 | -> | score: 100 | ++---------------------------+ +---------------------------+ + ^ + | ++------------------+ +| remaining_accounts| +| [0] = ABC123... | +| [1] = DEF456... | ++------------------+ +``` + +### 2.3 Why Pack Pubkeys? + +Compressed account instructions are serialized and stored in Merkle trees. Packing provides: + +| Aspect | Unpacked | Packed | Savings | +|--------|----------|--------|---------| +| Single Pubkey | 32 bytes | 1 byte | 31 bytes | +| Two Pubkeys | 64 bytes | 2 bytes | 62 bytes | + +The remaining accounts array stores actual Pubkeys, while instruction data contains only indices. + +--- + +## 3. Generated Items + +The macro generates different outputs based on whether the struct contains Pubkey fields: + +### With Pubkey Fields + +| Item | Type | Description | +|------|------|-------------| +| `Packed{StructName}` | Struct | New struct with Pubkeys replaced by `u8` | +| `Pack for StructName` | Trait impl | Converts struct to packed form | +| `Unpack for StructName` | Trait impl | Identity unpack (returns clone) | +| `Pack for Packed{StructName}` | Trait impl | Identity pack (returns clone) | +| `Unpack for Packed{StructName}` | Trait impl | Converts packed form back to original | + +### Without Pubkey Fields + +| Item | Type | Description | +|------|------|-------------| +| `Packed{StructName}` | Type alias | `type Packed{StructName} = {StructName}` | +| `Pack for StructName` | Trait impl | Identity pack (returns clone) | +| `Unpack for StructName` | Trait impl | Identity unpack (returns clone) | + +--- + +## 4. Trait Signatures + +### Pack Trait + +```rust +pub trait Pack { + type Packed; + + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed; +} +``` + +### Unpack Trait + +```rust +pub trait Unpack { + type Unpacked; + + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> Result; +} +``` + +--- + +## 5. Code Example - With Pubkey Fields + +### Input + +```rust +use anchor_lang::prelude::*; +use light_sdk::compressible::CompressionInfo; +use light_sdk_macros::CompressiblePack; + +#[derive(Clone, CompressiblePack)] +pub struct UserRecord { + pub owner: Pubkey, + pub authority: Pubkey, + pub score: u64, + pub compression_info: Option, +} +``` + +### Generated Output + +```rust +// Packed struct with Pubkeys replaced by u8 indices +#[derive(Debug, Clone, anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] +pub struct PackedUserRecord { + pub owner: u8, // Pubkey -> u8 index + pub authority: u8, // Pubkey -> u8 index + pub score: u64, // Non-Pubkey unchanged + pub compression_info: Option, +} + +// Pack original -> packed +impl light_sdk::compressible::Pack for UserRecord { + type Packed = PackedUserRecord; + + #[inline(never)] + fn pack(&self, remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + PackedUserRecord { + owner: remaining_accounts.insert_or_get(self.owner), + authority: remaining_accounts.insert_or_get(self.authority), + score: self.score, + compression_info: None, + } + } +} + +// Unpack original -> original (identity) +impl light_sdk::compressible::Unpack for UserRecord { + type Unpacked = Self; + + #[inline(never)] + fn unpack( + &self, + _remaining_accounts: &[anchor_lang::prelude::AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } +} + +// Pack packed -> packed (identity) +impl light_sdk::compressible::Pack for PackedUserRecord { + type Packed = Self; + + #[inline(never)] + fn pack(&self, _remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + self.clone() + } +} + +// Unpack packed -> original +impl light_sdk::compressible::Unpack for PackedUserRecord { + type Unpacked = UserRecord; + + #[inline(never)] + fn unpack( + &self, + remaining_accounts: &[anchor_lang::prelude::AccountInfo], + ) -> std::result::Result { + Ok(UserRecord { + owner: *remaining_accounts[self.owner as usize].key, + authority: *remaining_accounts[self.authority as usize].key, + score: self.score, + compression_info: None, + }) + } +} +``` + +--- + +## 6. Code Example - Without Pubkey Fields + +### Input + +```rust +use light_sdk::compressible::CompressionInfo; +use light_sdk_macros::CompressiblePack; + +#[derive(Clone, CompressiblePack)] +pub struct SimpleRecord { + pub id: u64, + pub value: u32, + pub compression_info: Option, +} +``` + +### Generated Output + +```rust +// Type alias instead of new struct +pub type PackedSimpleRecord = SimpleRecord; + +// Identity pack +impl light_sdk::compressible::Pack for SimpleRecord { + type Packed = SimpleRecord; + + #[inline(never)] + fn pack(&self, _remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + self.clone() + } +} + +// Identity unpack +impl light_sdk::compressible::Unpack for SimpleRecord { + type Unpacked = Self; + + #[inline(never)] + fn unpack( + &self, + _remaining_accounts: &[anchor_lang::prelude::AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } +} +``` + +--- + +## 7. Field Handling + +| Field Type | Pack Behavior | Unpack Behavior | +|------------|---------------|-----------------| +| `Pubkey` | `remaining_accounts.insert_or_get(pubkey)` -> `u8` | `*remaining_accounts[idx].key` -> `Pubkey` | +| `compression_info` | Always set to `None` | Always set to `None` | +| Copy types (`u64`, etc.) | Direct copy | Direct copy | +| Clone types (`String`, etc.) | `.clone()` | `.clone()` | + +### Pubkey Type Detection + +The macro recognizes these as Pubkey types: +- `Pubkey` +- `solana_pubkey::Pubkey` +- `anchor_lang::prelude::Pubkey` +- Other paths ending in `Pubkey` + +--- + +## 8. Usage in Instructions + +The pack/unpack system is used when building compressed account instructions: + +```rust +// Client-side: pack account data +let mut packed_accounts = PackedAccounts::new(); +let packed_record = user_record.pack(&mut packed_accounts); + +// On-chain: unpack from instruction data +let user_record = packed_record.unpack(ctx.remaining_accounts)?; +``` + +--- + +## 9. Usage Notes + +- The struct must implement `Clone` +- `compression_info` field is always set to `None` during pack/unpack +- All methods are marked `#[inline(never)]` for smaller program size +- The packed struct derives `AnchorSerialize` and `AnchorDeserialize` + +--- + +## 10. Related Macros + +| Macro | Relationship | +|-------|--------------| +| [`Compressible`](compressible.md) | Provides compression traits (separate concern) | +| [`LightCompressible`](light_compressible.md) | Includes `CompressiblePack` + all other traits | +| [`HasCompressionInfo`](has_compression_info.md) | Provides compression info accessors | diff --git a/sdk-libs/macros/docs/traits/has_compression_info.md b/sdk-libs/macros/docs/traits/has_compression_info.md new file mode 100644 index 0000000000..4d2ac58f6c --- /dev/null +++ b/sdk-libs/macros/docs/traits/has_compression_info.md @@ -0,0 +1,156 @@ +# HasCompressionInfo Derive Macro + +## 1. Overview + +The `#[derive(HasCompressionInfo)]` macro generates accessor methods for the `compression_info` field on compressible account structs. This trait is required for the Light Protocol compression system to read and write compression metadata. + +**When to use**: Apply this derive when you need only the compression info accessors, without the full `Compressible` or `LightCompressible` derives. + +**Source**: `sdk-libs/macros/src/rentfree/traits/traits.rs` (lines 46-88) + +--- + +## 2. How It Works + +### 2.1 Compile-Time Flow + +``` ++------------------+ +-------------------+ +------------------+ +| Input Struct | --> | Macro at | --> | Generated | +| | | Compile Time | | Code | ++------------------+ +-------------------+ +------------------+ +| pub struct User {| | 1. Find field | | impl HasCompres- | +| owner: Pubkey, | | "compression_ | | sionInfo for | +| compression_ | | info" | | User { ... } | +| info: Option< | | 2. Validate type | | | +| CompressionInfo| | 3. Generate impl | | | +| } | | | | | ++------------------+ +-------------------+ +------------------+ +``` + +### 2.2 Processing Steps + +1. **Field Extraction**: Macro extracts all named fields from the struct +2. **Validation**: Searches for `compression_info` field, errors if missing +3. **Code Generation**: Generates trait impl with hardcoded field access + +### 2.3 Runtime Behavior + +The generated methods provide access to compression metadata stored in the account: + +``` +Account State Method Call ++------------------------+ +------------------------+ +| compression_info: Some | --> | compression_info() | +| address: [u8; 32] | | Returns &CompressionInfo +| lamports: u64 | +------------------------+ +| ... | ++------------------------+ +------------------------+ +| compression_info: None | --> | compression_info() | +| | | PANICS! | ++------------------------+ +------------------------+ + | compression_info_mut_ | + | opt() - safe access | + +------------------------+ +``` + +--- + +## 3. Generated Trait + +The macro implements `light_sdk::compressible::HasCompressionInfo`: + +```rust +impl HasCompressionInfo for YourStruct { + fn compression_info(&self) -> &CompressionInfo; + fn compression_info_mut(&mut self) -> &mut CompressionInfo; + fn compression_info_mut_opt(&mut self) -> &mut Option; + fn set_compression_info_none(&mut self); +} +``` + +### Method Details + +| Method | Returns | Description | +|--------|---------|-------------| +| `compression_info()` | `&CompressionInfo` | Returns reference to compression info, panics if `None` | +| `compression_info_mut()` | `&mut CompressionInfo` | Returns mutable reference, panics if `None` | +| `compression_info_mut_opt()` | `&mut Option` | Returns mutable reference to the `Option` itself | +| `set_compression_info_none()` | `()` | Sets the field to `None` | + +--- + +## 4. Required Field + +The struct **must** have a field named `compression_info` of type `Option`: + +```rust +pub struct MyAccount { + pub data: u64, + pub compression_info: Option, // Required +} +``` + +If this field is missing, the macro will emit a compile error: + +``` +error: Struct must have a 'compression_info' field of type Option +``` + +--- + +## 5. Code Example + +### Input + +```rust +use light_sdk::compressible::CompressionInfo; +use light_sdk_macros::HasCompressionInfo; + +#[derive(HasCompressionInfo)] +pub struct UserRecord { + pub owner: Pubkey, + pub score: u64, + pub compression_info: Option, +} +``` + +### Generated Output + +```rust +impl light_sdk::compressible::HasCompressionInfo for UserRecord { + fn compression_info(&self) -> &light_sdk::compressible::CompressionInfo { + self.compression_info.as_ref().expect("compression_info must be set") + } + + fn compression_info_mut(&mut self) -> &mut light_sdk::compressible::CompressionInfo { + self.compression_info.as_mut().expect("compression_info must be set") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } +} +``` + +--- + +## 6. Usage Notes + +- The `compression_info()` and `compression_info_mut()` methods will panic if called when the field is `None`. Use `compression_info_mut_opt()` for safe access. +- This trait is automatically included when using `#[derive(Compressible)]` or `#[derive(LightCompressible)]`. +- The field must be named exactly `compression_info` (not `info`, `compress_info`, etc.). + +--- + +## 7. Related Macros + +| Macro | Relationship | +|-------|--------------| +| [`Compressible`](compressible.md) | Includes `HasCompressionInfo` + `CompressAs` + `Size` + `CompressedInitSpace` | +| [`LightCompressible`](light_compressible.md) | Includes all compression traits | +| [`CompressAs`](compress_as.md) | Uses `HasCompressionInfo` to access compression metadata | diff --git a/sdk-libs/macros/docs/traits/light_compressible.md b/sdk-libs/macros/docs/traits/light_compressible.md new file mode 100644 index 0000000000..34a9f026c7 --- /dev/null +++ b/sdk-libs/macros/docs/traits/light_compressible.md @@ -0,0 +1,315 @@ +# LightCompressible Derive Macro + +## 1. Overview + +The `#[derive(LightCompressible)]` macro is a convenience derive that combines all traits required for a fully compressible account. It is the recommended way to prepare account structs for Light Protocol's rent-free compression system. + +**When to use**: Apply this derive to any account struct that will be used with `#[rentfree]` in an Accounts struct. This is the standard approach for most use cases. + +**Source**: `sdk-libs/macros/src/rentfree/traits/light_compressible.rs` (lines 56-79) + +--- + +## 2. How It Works + +### 2.1 Compile-Time Expansion + +``` +#[derive(LightCompressible)] + | + v ++----------------------------------+ +| derive_rentfree_account() | +| (light_compressible.rs:56) | ++----------------------------------+ + | + +---> derive_light_hasher_sha() + | | + | v + | DataHasher + ToByteArray impls + | + +---> discriminator() + | | + | v + | LightDiscriminator impl + | + +---> derive_compressible() + | | + | v + | HasCompressionInfo + CompressAs + + | Size + CompressedInitSpace impls + | + +---> derive_compressible_pack() + | + v + Pack + Unpack impls + + Packed{Name} struct +``` + +### 2.2 Full Transformation Flow + +``` +INPUT GENERATED ++---------------------------+ +------------------------------------------+ +| #[derive(LightCompressible)] | // 8+ trait implementations | +| pub struct UserRecord { | | | +| pub owner: Pubkey, | | impl DataHasher for UserRecord { ... } | +| pub score: u64, | | impl ToByteArray for UserRecord { ... } | +| pub compression_info: | | impl LightDiscriminator for UserRecord { | +| Option | const LIGHT_DISCRIMINATOR = [...]; | +| } | | } | ++---------------------------+ | impl HasCompressionInfo for UserRecord { | + | fn compression_info() -> &... | + | fn compression_info_mut() -> &mut ... | + | } | + | impl CompressAs for UserRecord { ... } | + | impl Size for UserRecord { ... } | + | impl CompressedInitSpace for UserRecord {| + | impl Pack for UserRecord { ... } | + | impl Unpack for UserRecord { ... } | + | pub struct PackedUserRecord { ... } | + | impl Pack for PackedUserRecord { ... } | + | impl Unpack for PackedUserRecord { ... } | + +------------------------------------------+ +``` + +### 2.3 Role in Compression Lifecycle + +``` + COMPRESSION LIFECYCLE + ==================== + ++-------------------+ +-------------------+ +-------------------+ +| Data Struct | --> | Accounts Struct | --> | Runtime | ++-------------------+ +-------------------+ +-------------------+ +| #[derive( | | #[derive(Accounts,| | light_pre_init() | +| LightCompressible)] | RentFree)] | | Uses: | +| | | #[instruction] | | - DataHasher | +| Provides: | | pub struct Create | | - LightDiscrim. | +| - Hashing | | { | | - HasCompression| +| - Discriminator | | #[rentfree] | | Info | +| - Compression | | pub user_record | | - CompressAs | +| - Pack/Unpack | | } | | - Size | ++-------------------+ +-------------------+ | - Pack | + +-------------------+ +``` + +--- + +## 3. Generated Traits + +`LightCompressible` expands to four derive macros: + +| Derive | Traits Generated | +|--------|------------------| +| `LightHasherSha` | `DataHasher`, `ToByteArray` | +| `LightDiscriminator` | `LightDiscriminator` | +| `Compressible` | `HasCompressionInfo`, `CompressAs`, `Size`, `CompressedInitSpace` | +| `CompressiblePack` | `Pack`, `Unpack`, `Packed{Name}` struct | + +### Equivalent Manual Derives + +```rust +// This: +#[derive(LightCompressible)] +pub struct MyAccount { ... } + +// Is equivalent to: +#[derive(LightHasherSha, LightDiscriminator, Compressible, CompressiblePack)] +pub struct MyAccount { ... } +``` + +--- + +## 4. Required Field + +The struct **must** have a field named `compression_info` of type `Option`: + +```rust +pub struct MyAccount { + pub data: u64, + pub compression_info: Option, // Required +} +``` + +--- + +## 5. Supported Attributes + +### `#[compress_as(field = expr, ...)]` - Field Overrides + +Override specific field values in the compressed representation (passed to `Compressible` derive): + +```rust +#[derive(LightCompressible)] +#[compress_as(start_time = 0, cached_value = 0)] +pub struct GameSession { + pub session_id: u64, + pub player: Pubkey, + pub start_time: u64, // Will be 0 in compressed form + pub cached_value: u64, // Will be 0 in compressed form + pub compression_info: Option, +} +``` + +### `#[skip]` - Exclude Fields + +Mark fields to exclude from compression and size calculations: + +```rust +#[derive(LightCompressible)] +pub struct CachedData { + pub id: u64, + #[skip] // Excluded from compression + pub cached_timestamp: u64, + pub compression_info: Option, +} +``` + +--- + +## 6. Complete Code Example + +### Input + +```rust +use anchor_lang::prelude::*; +use light_sdk::compressible::CompressionInfo; +use light_sdk_macros::LightCompressible; + +#[derive(Default, Debug, Clone, InitSpace, LightCompressible)] +#[account] +pub struct UserRecord { + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub score: u64, + pub compression_info: Option, +} +``` + +### Generated Output Summary + +```rust +// From LightHasherSha: +impl light_hasher::DataHasher for UserRecord { ... } +impl light_hasher::ToByteArray for UserRecord { ... } + +// From LightDiscriminator: +impl light_sdk::discriminator::LightDiscriminator for UserRecord { + const LIGHT_DISCRIMINATOR: &'static [u8] = &[...]; // 8-byte unique ID +} + +// From Compressible: +impl light_sdk::compressible::HasCompressionInfo for UserRecord { ... } +impl light_sdk::compressible::CompressAs for UserRecord { ... } +impl light_sdk::account::Size for UserRecord { ... } +impl light_sdk::compressible::CompressedInitSpace for UserRecord { ... } + +// From CompressiblePack: +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct PackedUserRecord { + pub owner: u8, // Pubkey compressed to index + pub name: String, + pub score: u64, + pub compression_info: Option, +} +impl light_sdk::compressible::Pack for UserRecord { ... } +impl light_sdk::compressible::Unpack for UserRecord { ... } +impl light_sdk::compressible::Pack for PackedUserRecord { ... } +impl light_sdk::compressible::Unpack for PackedUserRecord { ... } +``` + +--- + +## 7. Hashing Behavior + +The `LightHasherSha` component uses SHA256 to hash the entire struct: + +- **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` + +--- + +## 8. Discriminator + +The `LightDiscriminator` component generates an 8-byte unique identifier: + +```rust +const LIGHT_DISCRIMINATOR: &'static [u8] = &[0x12, 0x34, ...]; // SHA256("light:UserRecord")[..8] +``` + +This discriminator is used to identify account types in compressed account data. + +--- + +## 9. Pubkey Packing + +If the struct contains `Pubkey` fields, `CompressiblePack` generates: + +- A `Packed{Name}` struct with `Pubkey` fields replaced by `u8` indices +- `Pack` implementation to convert to packed form +- `Unpack` implementation to restore from packed form + +If no `Pubkey` fields exist, identity implementations are generated instead. + +--- + +## 10. Usage with RentFree + +`LightCompressible` prepares the data struct for use with `#[derive(RentFree)]` on Accounts structs: + +```rust +// Data struct - apply LightCompressible +#[derive(Default, Debug, Clone, InitSpace, LightCompressible)] +#[account] +pub struct UserRecord { + pub owner: Pubkey, + pub score: u64, + pub compression_info: Option, +} + +// Accounts struct - apply RentFree +#[derive(Accounts, RentFree)] +#[instruction(params: CreateParams)] +pub struct Create<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[account(init, payer = fee_payer, space = 8 + UserRecord::INIT_SPACE, ...)] + #[rentfree] + pub user_record: Account<'info, UserRecord>, +} +``` + +--- + +## 11. Usage Notes + +- The struct must derive `Clone` (required by `CompressiblePack`) +- The struct should derive Anchor's `InitSpace` (required by `CompressedInitSpace`) +- The `compression_info` field is auto-detected and handled specially (no `#[skip]` needed) +- Only works with named-field structs, not tuple structs or unit structs +- Enums are not supported + +--- + +## 12. Error Conditions + +| Error | Cause | +|-------|-------| +| `LightCompressible can only be derived for structs` | Applied to enum or union | +| `Struct must have a 'compression_info' field` | Missing required field | + +--- + +## 13. Related Macros + +| Macro | Relationship | +|-------|--------------| +| [`HasCompressionInfo`](has_compression_info.md) | Included via `Compressible` | +| [`CompressAs`](compress_as.md) | Included via `Compressible` | +| [`Compressible`](compressible.md) | Included in `LightCompressible` | +| [`CompressiblePack`](compressible_pack.md) | Included in `LightCompressible` | +| [`RentFree`](../rentfree.md) | Uses traits from `LightCompressible` | diff --git a/sdk-libs/macros/src/rentfree/accounts/builder.rs b/sdk-libs/macros/src/rentfree/accounts/builder.rs new file mode 100644 index 0000000000..a6d0cee7bb --- /dev/null +++ b/sdk-libs/macros/src/rentfree/accounts/builder.rs @@ -0,0 +1,335 @@ +//! Builder for RentFree derive macro code generation. +//! +//! Encapsulates parsed struct data and resolved infrastructure fields, +//! providing methods for validation, querying, and code generation. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::DeriveInput; + +use super::{ + light_mint::{InfraRefs, LightMintBuilder}, + parse::ParsedRentFreeStruct, + pda::generate_pda_compress_blocks, +}; + +/// Builder for RentFree derive macro code generation. +/// +/// Encapsulates parsed struct data and resolved infrastructure fields, +/// providing methods for validation, querying, and code generation. +pub(super) struct RentFreeBuilder { + parsed: ParsedRentFreeStruct, + infra: InfraRefs, +} + +impl RentFreeBuilder { + /// Parse a DeriveInput and construct the builder. + pub fn parse(input: &DeriveInput) -> Result { + let parsed = super::parse::parse_rentfree_struct(input)?; + let infra = InfraRefs::from_parsed(&parsed.infra_fields); + Ok(Self { parsed, infra }) + } + + /// Validate constraints (e.g., account count < 255). + pub fn validate(&self) -> Result<(), syn::Error> { + let total = self.parsed.rentfree_fields.len() + self.parsed.light_mint_fields.len(); + if total > 255 { + return Err(syn::Error::new_spanned( + &self.parsed.struct_name, + format!( + "Too many compression fields ({} PDAs + {} mints = {} total, maximum 255). \ + Light Protocol uses u8 for account indices.", + self.parsed.rentfree_fields.len(), + self.parsed.light_mint_fields.len(), + total + ), + )); + } + Ok(()) + } + + /// Query: any #[rentfree] fields? + pub fn has_pdas(&self) -> bool { + !self.parsed.rentfree_fields.is_empty() + } + + /// Query: any #[light_mint] fields? + pub fn has_mints(&self) -> bool { + !self.parsed.light_mint_fields.is_empty() + } + + /// Query: #[instruction(...)] present? + pub fn has_instruction_args(&self) -> bool { + self.parsed.instruction_args.is_some() + } + + /// Generate no-op trait impls (for backwards compatibility). + pub fn generate_noop_impls(&self) -> Result { + let struct_name = &self.parsed.struct_name; + let (impl_generics, ty_generics, where_clause) = self.parsed.generics.split_for_impl(); + + Ok(quote! { + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightPreInit<'info, ()> for #struct_name #ty_generics #where_clause { + fn light_pre_init( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + _params: &(), + ) -> std::result::Result { + Ok(false) + } + } + + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightFinalize<'info, ()> for #struct_name #ty_generics #where_clause { + fn light_finalize( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + _params: &(), + _has_pre_init: bool, + ) -> std::result::Result<(), light_sdk::error::LightSdkError> { + Ok(()) + } + } + }) + } + + /// 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 + pub fn generate_pre_init_pdas_and_mints(&self) -> TokenStream { + let (compress_blocks, new_addr_idents) = + generate_pda_compress_blocks(&self.parsed.rentfree_fields); + let rentfree_count = self.parsed.rentfree_fields.len() as u8; + let pda_count = self.parsed.rentfree_fields.len(); + + // Get instruction param ident + let params_ident = &self + .parsed + .instruction_args + .as_ref() + .unwrap() + .first() + .unwrap() + .name; + + // 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_invocation(); + + // Infrastructure field references for quote! interpolation + let fee_payer = &self.infra.fee_payer; + let compression_config = &self.infra.compression_config; + + quote! { + // Build CPI accounts WITH CPI context for batching + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new_with_config( + &self.#fee_payer, + _remaining, + light_sdk_types::cpi_accounts::CpiAccountsConfig::new_with_cpi_context(crate::LIGHT_CPI_SIGNER), + ); + + // Load compression config + let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( + &self.#compression_config, + &crate::ID + )?; + + // Collect compressed infos for all rentfree PDA accounts + let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); + #(#compress_blocks)* + + // Step 1: Write PDAs to CPI context + let cpi_context_account = cpi_accounts.cpi_context()?; + let cpi_context_accounts = light_sdk_types::cpi_context_write::CpiContextWriteAccounts { + fee_payer: cpi_accounts.fee_payer(), + authority: cpi_accounts.authority()?, + cpi_context: cpi_context_account, + cpi_signer: crate::LIGHT_CPI_SIGNER, + }; + + use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; + light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( + crate::LIGHT_CPI_SIGNER, + #params_ident.create_accounts_proof.proof.clone() + ) + .with_new_addresses(&[#(#new_addr_idents),*]) + .with_account_infos(&all_compressed_infos) + .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 + #mint_invocation + + Ok(true) + } + } + + /// Generate LightPreInit body for mints-only (no PDAs): + /// Invoke mint_action with decompress directly + /// After this, CMint is "hot" and usable in instruction body + pub fn generate_pre_init_mints_only(&self) -> TokenStream { + // Get instruction param ident + let params_ident = &self + .parsed + .instruction_args + .as_ref() + .unwrap() + .first() + .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 + let mint_invocation = + LightMintBuilder::new(mint, 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( + &self.#fee_payer, + _remaining, + crate::LIGHT_CPI_SIGNER, + ); + + // Build and invoke mint_action with decompress + #mint_invocation + + Ok(true) + } + } + + /// Generate LightPreInit body for PDAs only (no mints) + /// After this, compressed addresses are registered + pub fn generate_pre_init_pdas_only(&self) -> TokenStream { + let (compress_blocks, new_addr_idents) = + generate_pda_compress_blocks(&self.parsed.rentfree_fields); + let rentfree_count = self.parsed.rentfree_fields.len() as u8; + + // Get instruction param ident + let params_ident = &self + .parsed + .instruction_args + .as_ref() + .unwrap() + .first() + .unwrap() + .name; + + // Infra field references + let fee_payer = &self.infra.fee_payer; + let compression_config = &self.infra.compression_config; + + quote! { + // Build CPI accounts (no CPI context needed for PDAs-only) + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( + &self.#fee_payer, + _remaining, + crate::LIGHT_CPI_SIGNER, + ); + + // Load compression config + let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( + &self.#compression_config, + &crate::ID + )?; + + // Collect compressed infos for all rentfree accounts + let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); + #(#compress_blocks)* + + // Execute Light System Program CPI directly with proof + use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; + light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( + crate::LIGHT_CPI_SIGNER, + #params_ident.create_accounts_proof.proof.clone() + ) + .with_new_addresses(&[#(#new_addr_idents),*]) + .with_account_infos(&all_compressed_infos) + .invoke(cpi_accounts)?; + + Ok(true) + } + } + + /// Generate LightPreInit trait implementation. + pub fn generate_pre_init_impl(&self, body: TokenStream) -> TokenStream { + let struct_name = &self.parsed.struct_name; + let (impl_generics, ty_generics, where_clause) = self.parsed.generics.split_for_impl(); + + let first_arg = self + .parsed + .instruction_args + .as_ref() + .and_then(|args| args.first()) + .unwrap(); + + let params_type = &first_arg.ty; + let params_ident = &first_arg.name; + + quote! { + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightPreInit<'info, #params_type> for #struct_name #ty_generics #where_clause { + fn light_pre_init( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + #params_ident: &#params_type, + ) -> std::result::Result { + use anchor_lang::ToAccountInfo; + #body + } + } + } + } + + /// Generate LightFinalize trait implementation. + pub fn generate_finalize_impl(&self, body: TokenStream) -> TokenStream { + let struct_name = &self.parsed.struct_name; + let (impl_generics, ty_generics, where_clause) = self.parsed.generics.split_for_impl(); + + let first_arg = self + .parsed + .instruction_args + .as_ref() + .and_then(|args| args.first()) + .unwrap(); + + let params_type = &first_arg.ty; + let params_ident = &first_arg.name; + + quote! { + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightFinalize<'info, #params_type> for #struct_name #ty_generics #where_clause { + fn light_finalize( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + #params_ident: &#params_type, + _has_pre_init: bool, + ) -> std::result::Result<(), light_sdk::error::LightSdkError> { + use anchor_lang::ToAccountInfo; + #body + } + } + } + } +} diff --git a/sdk-libs/macros/src/rentfree/accounts/derive.rs b/sdk-libs/macros/src/rentfree/accounts/derive.rs new file mode 100644 index 0000000000..0de5ce2c34 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/accounts/derive.rs @@ -0,0 +1,55 @@ +//! Orchestration layer for RentFree derive macro. +//! +//! This module coordinates code generation by combining: +//! - PDA block generation from `pda.rs` +//! - Mint action invocation from `light_mint.rs` +//! - Parsing results from `parse.rs` +//! +//! Design for mints: +//! - At mint init, we CREATE + DECOMPRESS atomically +//! - After init, the CMint should always be in decompressed/"hot" state +//! +//! Flow for PDAs + mints: +//! 1. Pre-init: ALL compression logic executes here +//! a. Write PDAs to CPI context +//! b. Invoke mint_action with decompress + CPI context +//! c. CMint is now "hot" and usable +//! 2. Instruction body: Can use hot CMint (mintTo, transfers, etc.) +//! 3. Finalize: No-op (all work done in pre_init) + +use proc_macro2::TokenStream; +use quote::quote; +use syn::DeriveInput; + +use super::builder::RentFreeBuilder; + +/// Main orchestration - shows the high-level flow clearly. +pub(super) fn derive_rentfree(input: &DeriveInput) -> Result { + let builder = RentFreeBuilder::parse(input)?; + builder.validate()?; + + // No instruction args = no-op impls (backwards compatibility) + if !builder.has_instruction_args() { + return builder.generate_noop_impls(); + } + + // Generate pre_init body based on what fields we have + let pre_init = if builder.has_pdas() && builder.has_mints() { + builder.generate_pre_init_pdas_and_mints() + } else if builder.has_mints() { + builder.generate_pre_init_mints_only() + } else if builder.has_pdas() { + builder.generate_pre_init_pdas_only() + } else { + quote! { Ok(false) } + }; + + // Generate trait implementations + let pre_init_impl = builder.generate_pre_init_impl(pre_init); + let finalize_impl = builder.generate_finalize_impl(quote! { Ok(()) }); + + Ok(quote! { + #pre_init_impl + #finalize_impl + }) +} diff --git a/sdk-libs/macros/src/rentfree/accounts/light_mint.rs b/sdk-libs/macros/src/rentfree/accounts/light_mint.rs index 1f45f12677..cbdbebfa40 100644 --- a/sdk-libs/macros/src/rentfree/accounts/light_mint.rs +++ b/sdk-libs/macros/src/rentfree/accounts/light_mint.rs @@ -3,34 +3,32 @@ //! This module handles: //! - Parsing of #[light_mint(...)] attributes using darling //! - Code generation for mint_action CPI invocations +//! +//! ## Parsed Attributes +//! +//! Required: `mint_signer`, `authority`, `decimals`, `mint_seeds` +//! Optional: `address_tree_info`, `freeze_authority`, `authority_seeds`, `rent_payment`, `write_top_up` +//! +//! ## Code Generation +//! +//! Two cases for mint_action CPI: +//! - **With CPI context**: Batching mint creation with PDA compression +//! - **Without CPI context**: Mint-only instructions +//! +//! See `CpiContextParts` for what differs between these cases. use darling::FromMeta; use proc_macro2::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::{Expr, Ident}; +use super::parse::InfraFields; +use crate::rentfree::shared_utils::MetaExpr; + // ============================================================================ // Parsing with darling // ============================================================================ -/// Wrapper for syn::Expr that implements darling's FromMeta trait. -/// Enables darling to parse arbitrary expressions in attributes like -/// `#[light_mint(mint_signer = self.authority)]`. -#[derive(Clone)] -struct MetaExpr(Expr); - -impl FromMeta for MetaExpr { - fn from_expr(expr: &Expr) -> darling::Result { - Ok(MetaExpr(expr.clone())) - } -} - -impl From for Expr { - fn from(meta: MetaExpr) -> Expr { - meta.0 - } -} - /// A field marked with #[light_mint(...)] pub(super) struct LightMintField { /// The field name where #[light_mint] is attached (CMint account) @@ -44,9 +42,11 @@ pub(super) struct LightMintField { /// Address tree info expression pub address_tree_info: Expr, /// Optional freeze authority - pub freeze_authority: Option, - /// Signer seeds for the mint_signer PDA (required if mint_signer is a PDA) - pub signer_seeds: Option, + pub freeze_authority: Option, + /// Signer seeds for the mint_signer PDA (required) + pub mint_seeds: Expr, + /// Signer seeds for the authority PDA (optional - if not provided, authority must be a tx signer) + pub authority_seeds: Option, /// Rent payment epochs for decompression (default: 2) pub rent_payment: Option, /// Write top-up lamports for decompression (default: 0) @@ -56,7 +56,7 @@ pub(super) struct LightMintField { /// Arguments inside #[light_mint(...)] parsed by darling. /// /// Required fields (darling auto-validates): mint_signer, authority, decimals -/// Optional fields: address_tree_info, freeze_authority, signer_seeds, rent_payment, write_top_up +/// Optional fields: address_tree_info, freeze_authority, mint_seeds, authority_seeds, rent_payment, write_top_up #[derive(FromMeta)] struct LightMintArgs { /// The mint_signer field (AccountInfo that seeds the mint PDA) - REQUIRED @@ -68,12 +68,14 @@ struct LightMintArgs { /// Address tree info expression (defaults to params.create_accounts_proof.address_tree_info) #[darling(default)] address_tree_info: Option, - /// Optional freeze authority + /// Optional freeze authority (field name, e.g., `freeze_authority = freeze_auth`) #[darling(default)] - freeze_authority: Option, - /// Signer seeds for the mint_signer PDA (required if mint_signer is a PDA) + freeze_authority: Option, + /// Signer seeds for the mint_signer PDA (required) + mint_seeds: MetaExpr, + /// Signer seeds for the authority PDA (optional - if not provided, authority must be a tx signer) #[darling(default)] - signer_seeds: Option, + authority_seeds: Option, /// Rent payment epochs for decompression #[darling(default)] rent_payment: Option, @@ -105,8 +107,9 @@ pub(super) fn parse_light_mint_attr( authority: args.authority.into(), decimals: args.decimals.into(), address_tree_info, - freeze_authority: args.freeze_authority.map(Into::into), - signer_seeds: args.signer_seeds.map(Into::into), + freeze_authority: args.freeze_authority, + mint_seeds: args.mint_seeds.into(), + authority_seeds: args.authority_seeds.map(Into::into), rent_payment: args.rent_payment.map(Into::into), write_top_up: args.write_top_up.map(Into::into), })); @@ -119,153 +122,264 @@ pub(super) fn parse_light_mint_attr( // Code Generation // ============================================================================ -/// Default rent payment period in epochs (how long to prepay rent for decompressed accounts). -const DEFAULT_RENT_PAYMENT_EPOCHS: u8 = 2; +/// Quote an optional expression, using default if None. +fn quote_option_or(opt: &Option, default: TokenStream) -> TokenStream { + opt.as_ref().map(|e| quote! { #e }).unwrap_or(default) +} -/// Default write top-up in lamports (additional lamports for write operations during decompression). -const DEFAULT_WRITE_TOP_UP_LAMPORTS: u32 = 0; +/// Resolve optional field name to TokenStream, using default if None. +fn resolve_field_name(field: &Option, default: &str) -> TokenStream { + field.as_ref().map(|f| quote! { #f }).unwrap_or_else(|| { + let ident = format_ident!("{}", default); + quote! { #ident } + }) +} -/// Generate token stream for signer seeds (explicit or empty default) -fn generate_signer_seeds_tokens(signer_seeds: &Option) -> TokenStream { - if let Some(seeds) = signer_seeds { - quote! { #seeds } - } else { - quote! { &[] as &[&[u8]] } - } +/// Resolved infrastructure field names as TokenStreams. +/// +/// Single source of truth for infrastructure fields used across code generation. +pub(super) struct InfraRefs { + pub fee_payer: TokenStream, + pub compression_config: TokenStream, + pub ctoken_config: TokenStream, + pub ctoken_rent_sponsor: TokenStream, + pub light_token_program: TokenStream, + pub ctoken_cpi_authority: TokenStream, } -/// Generate token stream for freeze authority expression -fn generate_freeze_authority_tokens(freeze_authority: &Option) -> TokenStream { - if let Some(freeze_auth) = freeze_authority { - quote! { Some(*self.#freeze_auth.to_account_info().key) } - } else { - quote! { None } +impl InfraRefs { + /// Construct from parsed InfraFields, applying defaults for missing fields. + pub fn from_parsed(infra: &InfraFields) -> Self { + Self { + fee_payer: resolve_field_name(&infra.fee_payer, "fee_payer"), + compression_config: resolve_field_name(&infra.compression_config, "compression_config"), + ctoken_config: resolve_field_name(&infra.ctoken_config, "ctoken_compressible_config"), + ctoken_rent_sponsor: resolve_field_name( + &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", + ), + } } } -/// Generate token stream for rent payment with default -fn generate_rent_payment_tokens(rent_payment: &Option) -> TokenStream { - if let Some(rent) = rent_payment { - quote! { #rent } - } else { - let default = DEFAULT_RENT_PAYMENT_EPOCHS; - quote! { #default } - } +/// Parts of generated code that differ based on CPI context presence. +/// +/// - **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, } -/// Generate token stream for write top-up with default -fn generate_write_top_up_tokens(write_top_up: &Option) -> TokenStream { - if let Some(top_up) = write_top_up { - quote! { #top_up } - } else { - let default = DEFAULT_WRITE_TOP_UP_LAMPORTS; - quote! { #default } +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 }, + }, + } } } -/// Configuration for mint_action CPI generation -pub(super) struct MintActionConfig<'a> { - pub mint: &'a LightMintField, - pub params_ident: &'a syn::Ident, - pub fee_payer: &'a TokenStream, - pub ctoken_config: &'a TokenStream, - pub ctoken_rent_sponsor: &'a TokenStream, - pub light_token_program: &'a TokenStream, - pub ctoken_cpi_authority: &'a TokenStream, - /// CPI context config: (output_tree_index_expr, assigned_account_index) - /// None = no CPI context (mints-only case) - pub cpi_context: Option<(TokenStream, u8)>, +/// Builder for mint code generation. +/// +/// Usage: +/// ```ignore +/// LightMintBuilder::new(mint, params_ident, &infra) +/// .with_cpi_context(quote! { #first_pda_output_tree }, mint_assigned_index) +/// .generate_invocation() +/// ``` +pub(super) struct LightMintBuilder<'a> { + mint: &'a LightMintField, + params_ident: &'a Ident, + infra: &'a InfraRefs, + cpi_context: Option<(TokenStream, u8)>, } -/// Generate mint_action invocation with optional CPI context -pub(super) fn generate_mint_action_invocation(config: &MintActionConfig) -> TokenStream { - let MintActionConfig { - mint, - params_ident, - fee_payer, - ctoken_config, - ctoken_rent_sponsor, - light_token_program, - ctoken_cpi_authority, - cpi_context, - } = config; +impl<'a> LightMintBuilder<'a> { + /// Create builder with required fields. + pub fn new(mint: &'a LightMintField, params_ident: &'a Ident, infra: &'a InfraRefs) -> Self { + Self { + mint, + params_ident, + infra, + cpi_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)); + self + } + + /// Generate mint_action CPI invocation code. + pub fn generate_invocation(self) -> TokenStream { + generate_mint_invocation(&self) + } +} +/// Generate mint_action invocation code. +/// +/// 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; + 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 signer_seeds_tokens = generate_signer_seeds_tokens(&mint.signer_seeds); - let freeze_authority_tokens = generate_freeze_authority_tokens(&mint.freeze_authority); - let rent_payment_tokens = generate_rent_payment_tokens(&mint.rent_payment); - let write_top_up_tokens = generate_write_top_up_tokens(&mint.write_top_up); - - // Queue access differs based on CPI context presence - let queue_access = if cpi_context.is_some() { - quote! { __output_tree_index as usize } - } else { - quote! { __tree_info.address_queue_pubkey_index as usize } - }; - - // CPI context setup block (empty if no CPI context) - let cpi_context_setup = if let Some((output_tree_expr, _)) = cpi_context { - quote! { - let __output_tree_index = #output_tree_expr; + 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) => { + quote! { + let authority_seeds: &[&[u8]] = #auth_seeds; + anchor_lang::solana_program::program::invoke_signed( + &mint_action_ix, + &account_infos, + &[mint_seeds, authority_seeds] + )?; + } } - } else { - quote! {} - }; - - // CPI context chain method (empty if no CPI context) - let cpi_context_chain = if let Some((output_tree_expr, assigned_idx)) = cpi_context { - 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: #output_tree_expr + 1, - in_queue_index: #output_tree_expr, - out_queue_index: #output_tree_expr, - token_out_queue_index: 0, - assigned_account_index: #assigned_idx, - read_only_address_trees: [0; 4], - }) + None => { + // authority_seeds not provided - authority must be a transaction signer + 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] + )?; + } } - } else { - quote! {} - }; - - // CPI context on meta_config (only if CPI context present) - let meta_cpi_context = if cpi_context.is_some() { - quote! { meta_config.cpi_context = Some(*cpi_accounts.cpi_context()?.key); } - } else { - quote! {} - }; - - // Use `let mut` only when CPI context needs modification - let instruction_data_binding = if cpi_context.is_some() { - quote! { let mut instruction_data } - } else { - quote! { let instruction_data } }; + // ------------------------------------------------------------------------- + // 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_context_setup + #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 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_tokens; + 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, @@ -281,17 +395,19 @@ pub(super) fn generate_mint_action_invocation(config: &MintActionConfig) -> Toke extensions: None, }; - #instruction_data_binding = light_token_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( + // Step 5: Build compressed instruction data with decompress config + #data_binding = light_token_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( __tree_info.root_index, __proof, compressed_mint_data, ) .with_decompress_mint(light_token_interface::instructions::mint_action::DecompressMintAction { - rent_payment: #rent_payment_tokens, - write_top_up: #write_top_up_tokens, + rent_payment: #rent_payment, + write_top_up: #write_top_up, }) - #cpi_context_chain; + #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, @@ -305,20 +421,23 @@ pub(super) fn generate_mint_action_invocation(config: &MintActionConfig) -> Toke *self.#ctoken_rent_sponsor.to_account_info().key, ); - #meta_cpi_context + #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()); @@ -329,12 +448,9 @@ pub(super) fn generate_mint_action_invocation(config: &MintActionConfig) -> Toke account_infos.push(self.#mint_signer.to_account_info()); account_infos.push(self.#fee_payer.to_account_info()); - let signer_seeds: &[&[u8]] = #signer_seeds_tokens; - if signer_seeds.is_empty() { - anchor_lang::solana_program::program::invoke(&mint_action_ix, &account_infos)?; - } else { - anchor_lang::solana_program::program::invoke_signed(&mint_action_ix, &account_infos, &[signer_seeds])?; - } + // Step 10: Invoke CPI with signer seeds + let mint_seeds: &[&[u8]] = #mint_seeds; + #invoke_signed_call } } } diff --git a/sdk-libs/macros/src/rentfree/accounts/mod.rs b/sdk-libs/macros/src/rentfree/accounts/mod.rs index c10cc6a7c4..8ca7039210 100644 --- a/sdk-libs/macros/src/rentfree/accounts/mod.rs +++ b/sdk-libs/macros/src/rentfree/accounts/mod.rs @@ -4,7 +4,15 @@ //! - `LightPreInit` trait implementation for pre-instruction compression setup //! - `LightFinalize` trait implementation for post-instruction cleanup //! - Supports rent-free PDAs, rent-free token accounts, and light mints +//! +//! Module structure: +//! - `parse.rs` - Parsing #[rentfree] and #[light_mint] attributes +//! - `pda.rs` - PDA block code generation +//! - `light_mint.rs` - Mint action invocation code generation +//! - `derive.rs` - Orchestration layer that wires everything together +mod builder; +mod derive; mod light_mint; mod parse; mod pda; @@ -13,8 +21,5 @@ use proc_macro2::TokenStream; use syn::DeriveInput; pub fn derive_rentfree(input: DeriveInput) -> Result { - let parsed = parse::parse_rentfree_struct(&input)?; - pda::generate_rentfree_impl(&parsed) + derive::derive_rentfree(&input) } - -// TODO: add a codegen file that puts the generated code together diff --git a/sdk-libs/macros/src/rentfree/accounts/parse.rs b/sdk-libs/macros/src/rentfree/accounts/parse.rs index cc3dfebe4a..fcf1932847 100644 --- a/sdk-libs/macros/src/rentfree/accounts/parse.rs +++ b/sdk-libs/macros/src/rentfree/accounts/parse.rs @@ -9,27 +9,72 @@ use syn::{ // Import LightMintField and parsing from light_mint module use super::light_mint::{parse_light_mint_attr, LightMintField}; -// Import shared types from seed_extraction module +use crate::rentfree::shared_utils::MetaExpr; +// Import shared types pub(super) use crate::rentfree::traits::seed_extraction::extract_account_inner_type; // ============================================================================ -// darling support for parsing Expr from attributes +// Infrastructure Field Classification // ============================================================================ -/// Wrapper for syn::Expr that implements darling's FromMeta trait. -/// Enables darling to parse arbitrary expressions in attributes. -#[derive(Clone)] -struct MetaExpr(Expr); +/// Classification of infrastructure fields by naming convention. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InfraFieldType { + FeePayer, + CompressionConfig, + CTokenConfig, + CTokenRentSponsor, + CTokenProgram, + CTokenCpiAuthority, +} + +/// Classifier for infrastructure fields by naming convention. +pub(super) struct InfraFieldClassifier; -impl FromMeta for MetaExpr { - fn from_expr(expr: &Expr) -> darling::Result { - Ok(MetaExpr(expr.clone())) +impl InfraFieldClassifier { + /// Classify a field name into its infrastructure type, if any. + #[inline] + pub fn classify(name: &str) -> Option { + match name { + "fee_payer" | "payer" | "creator" => Some(InfraFieldType::FeePayer), + "compression_config" => Some(InfraFieldType::CompressionConfig), + "ctoken_compressible_config" | "ctoken_config" | "light_token_config_account" => { + Some(InfraFieldType::CTokenConfig) + } + "ctoken_rent_sponsor" | "light_token_rent_sponsor" => { + Some(InfraFieldType::CTokenRentSponsor) + } + "ctoken_program" | "light_token_program" => Some(InfraFieldType::CTokenProgram), + "ctoken_cpi_authority" + | "light_token_program_cpi_authority" + | "compress_token_program_cpi_authority" => Some(InfraFieldType::CTokenCpiAuthority), + _ => None, + } } } -impl From for Expr { - fn from(meta: MetaExpr) -> Expr { - meta.0 +/// Collected infrastructure field identifiers. +#[derive(Default)] +pub(super) struct InfraFields { + pub fee_payer: Option, + pub compression_config: Option, + pub ctoken_config: Option, + pub ctoken_rent_sponsor: Option, + pub ctoken_program: Option, + pub ctoken_cpi_authority: Option, +} + +impl InfraFields { + /// Set an infrastructure field by type. + pub fn set(&mut self, field_type: InfraFieldType, ident: Ident) { + match field_type { + InfraFieldType::FeePayer => self.fee_payer = Some(ident), + InfraFieldType::CompressionConfig => self.compression_config = Some(ident), + InfraFieldType::CTokenConfig => self.ctoken_config = Some(ident), + InfraFieldType::CTokenRentSponsor => self.ctoken_rent_sponsor = Some(ident), + InfraFieldType::CTokenProgram => self.ctoken_program = Some(ident), + InfraFieldType::CTokenCpiAuthority => self.ctoken_cpi_authority = Some(ident), + } } } @@ -40,23 +85,16 @@ pub(super) struct ParsedRentFreeStruct { pub rentfree_fields: Vec, pub light_mint_fields: Vec, pub instruction_args: Option>, - pub fee_payer_field: Option, - pub compression_config_field: Option, - /// CToken compressible config account (for decompress mint) - pub ctoken_config_field: Option, - /// CToken rent sponsor account (for decompress mint) - pub ctoken_rent_sponsor_field: Option, - /// CToken program account (for decompress mint CPI) - pub ctoken_program_field: Option, - /// CToken CPI authority PDA (for decompress mint CPI) - pub ctoken_cpi_authority_field: Option, + /// Infrastructure fields detected by naming convention. + pub infra_fields: InfraFields, } /// A field marked with #[rentfree(...)] pub(super) struct RentFreeField { pub ident: Ident, /// The inner type T from Account<'info, T> or Box> - pub inner_type: Ident, + /// Preserves the full type path (e.g., crate::state::UserRecord). + pub inner_type: Type, pub address_tree_info: Expr, pub output_tree: Expr, /// True if the field is Box>, false if Account @@ -130,12 +168,7 @@ pub(super) fn parse_rentfree_struct(input: &DeriveInput) -> Result Result { - fee_payer_field = Some(field_ident.clone()); - } - "compression_config" => { - compression_config_field = Some(field_ident.clone()); - } - "ctoken_compressible_config" | "ctoken_config" | "light_token_config_account" => { - ctoken_config_field = Some(field_ident.clone()); - } - "ctoken_rent_sponsor" | "light_token_rent_sponsor" => { - ctoken_rent_sponsor_field = Some(field_ident.clone()); - } - "ctoken_program" | "light_token_program" => { - ctoken_program_field = Some(field_ident.clone()); - } - "ctoken_cpi_authority" - | "light_token_program_cpi_authority" - | "compress_token_program_cpi_authority" => { - ctoken_cpi_authority_field = Some(field_ident.clone()); - } - _ => {} + // Track infrastructure fields by naming convention using the classifier. + // See InfraFieldClassifier for supported field names. + if let Some(field_type) = InfraFieldClassifier::classify(&field_name) { + infra_fields.set(field_type, field_ident.clone()); } // Track if this field already has a compression attribute @@ -262,11 +259,6 @@ pub(super) fn parse_rentfree_struct(input: &DeriveInput) -> Result, default: &str) -> TokenStream { - field.as_ref().map(|f| quote! { #f }).unwrap_or_else(|| { - let ident = format_ident!("{}", default); - quote! { #ident } - }) +use syn::Ident; + +use super::parse::RentFreeField; + +/// Generated identifier names for a PDA field. +pub(super) struct PdaIdents { + pub idx: u8, + pub new_addr_params: Ident, + pub compressed_infos: Ident, + pub address: Ident, + pub account_info: Ident, + pub account_key: Ident, + pub account_data: Ident, } -/// Generate both trait implementations. -/// -/// Returns `Err` if the parsed struct has inconsistent state (e.g., params type without ident). -pub(super) fn generate_rentfree_impl( - parsed: &ParsedRentFreeStruct, -) -> Result { - let struct_name = &parsed.struct_name; - let (impl_generics, ty_generics, where_clause) = parsed.generics.split_for_impl(); - - // Validation: Ensure combined PDA + mint count fits in u8 (Light Protocol uses u8 for account indices) - let total_accounts = parsed.rentfree_fields.len() + parsed.light_mint_fields.len(); - if total_accounts > 255 { - return Err(syn::Error::new_spanned( - struct_name, - format!( - "Too many compression fields ({} PDAs + {} mints = {} total, maximum 255). \ - Light Protocol uses u8 for account indices.", - parsed.rentfree_fields.len(), - parsed.light_mint_fields.len(), - total_accounts - ), - )); - } - - // Extract first instruction arg or generate no-op impls - let first_arg = match parsed - .instruction_args - .as_ref() - .and_then(|args| args.first()) - { - Some(arg) => arg, - None => { - // No instruction args - generate no-op impls. - // Keep these for backwards compatibility with structs that derive RentFree - // without compression fields or instruction params. - return Ok(quote! { - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightPreInit<'info, ()> for #struct_name #ty_generics #where_clause { - fn light_pre_init( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - _params: &(), - ) -> std::result::Result { - Ok(false) - } - } - - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightFinalize<'info, ()> for #struct_name #ty_generics #where_clause { - fn light_finalize( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - _params: &(), - _has_pre_init: bool, - ) -> std::result::Result<(), light_sdk::error::LightSdkError> { - Ok(()) - } - } - }); - } - }; - - let params_type = &first_arg.ty; - let params_ident = &first_arg.name; - - let has_pdas = !parsed.rentfree_fields.is_empty(); - let has_mints = !parsed.light_mint_fields.is_empty(); - - // Resolve field names with defaults - let fee_payer = resolve_field_name(&parsed.fee_payer_field, "fee_payer"); - let compression_config = - resolve_field_name(&parsed.compression_config_field, "compression_config"); - let ctoken_config = - resolve_field_name(&parsed.ctoken_config_field, "ctoken_compressible_config"); - let ctoken_rent_sponsor = - resolve_field_name(&parsed.ctoken_rent_sponsor_field, "ctoken_rent_sponsor"); - let light_token_program = - resolve_field_name(&parsed.ctoken_program_field, "light_token_program"); - let ctoken_cpi_authority = - resolve_field_name(&parsed.ctoken_cpi_authority_field, "ctoken_cpi_authority"); - - // Generate LightPreInit impl based on what we have - // ALL compression logic runs in pre_init so instruction body can use hot state - let pre_init_body = if has_pdas && has_mints { - // PDAs + mints: Write PDAs to CPI context, then invoke mint_action with decompress - generate_pre_init_pdas_and_mints( - parsed, - params_ident, - &fee_payer, - &compression_config, - &ctoken_config, - &ctoken_rent_sponsor, - &light_token_program, - &ctoken_cpi_authority, - ) - } else if has_mints { - // Mints only: Invoke mint_action with decompress (no CPI context) - generate_pre_init_mints_only( - parsed, - params_ident, - &fee_payer, - &ctoken_config, - &ctoken_rent_sponsor, - &light_token_program, - &ctoken_cpi_authority, - ) - } else if has_pdas { - // PDAs only: Direct invoke (no CPI context needed) - generate_pre_init_pdas_only(parsed, params_ident, &fee_payer, &compression_config) - } else { - quote! { Ok(false) } - }; - - // LightFinalize: No-op (all work done in pre_init) - let finalize_body = quote! { Ok(()) }; - - Ok(quote! { - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightPreInit<'info, #params_type> for #struct_name #ty_generics #where_clause { - fn light_pre_init( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - #params_ident: &#params_type, - ) -> std::result::Result { - use anchor_lang::ToAccountInfo; - #pre_init_body - } - } - - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightFinalize<'info, #params_type> for #struct_name #ty_generics #where_clause { - fn light_finalize( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - #params_ident: &#params_type, - _has_pre_init: bool, - ) -> std::result::Result<(), light_sdk::error::LightSdkError> { - use anchor_lang::ToAccountInfo; - #finalize_body - } +impl PdaIdents { + pub fn new(idx: usize) -> Self { + Self { + idx: idx as u8, + new_addr_params: format_ident!("__new_addr_params_{}", idx), + compressed_infos: format_ident!("__compressed_infos_{}", idx), + address: format_ident!("__address_{}", idx), + account_info: format_ident!("__account_info_{}", idx), + account_key: format_ident!("__account_key_{}", idx), + account_data: format_ident!("__account_data_{}", idx), } - }) -} - -/// 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 -#[allow(clippy::too_many_arguments)] -fn generate_pre_init_pdas_and_mints( - parsed: &ParsedRentFreeStruct, - params_ident: &syn::Ident, - fee_payer: &TokenStream, - compression_config: &TokenStream, - ctoken_config: &TokenStream, - ctoken_rent_sponsor: &TokenStream, - light_token_program: &TokenStream, - ctoken_cpi_authority: &TokenStream, -) -> TokenStream { - let (compress_blocks, new_addr_idents) = generate_pda_compress_blocks(&parsed.rentfree_fields); - let rentfree_count = parsed.rentfree_fields.len() as u8; - let pda_count = parsed.rentfree_fields.len(); - - // Get the first PDA's output tree index (for the state tree output queue) - let first_pda_output_tree = &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 = &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 = generate_mint_action_invocation(&MintActionConfig { - mint, - params_ident, - fee_payer, - ctoken_config, - ctoken_rent_sponsor, - light_token_program, - ctoken_cpi_authority, - cpi_context: Some((quote! { #first_pda_output_tree }, mint_assigned_index)), - }); - - quote! { - // Build CPI accounts WITH CPI context for batching - let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new_with_config( - &self.#fee_payer, - _remaining, - light_sdk_types::cpi_accounts::CpiAccountsConfig::new_with_cpi_context(crate::LIGHT_CPI_SIGNER), - ); - - // Load compression config - let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( - &self.#compression_config, - &crate::ID - )?; - - // Collect compressed infos for all rentfree PDA accounts - let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); - #(#compress_blocks)* - - // Step 1: Write PDAs to CPI context - let cpi_context_account = cpi_accounts.cpi_context()?; - let cpi_context_accounts = light_sdk_types::cpi_context_write::CpiContextWriteAccounts { - fee_payer: cpi_accounts.fee_payer(), - authority: cpi_accounts.authority()?, - cpi_context: cpi_context_account, - cpi_signer: crate::LIGHT_CPI_SIGNER, - }; - - use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; - light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( - crate::LIGHT_CPI_SIGNER, - #params_ident.create_accounts_proof.proof.clone() - ) - .with_new_addresses(&[#(#new_addr_idents),*]) - .with_account_infos(&all_compressed_infos) - .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 - #mint_invocation - - Ok(true) } } -/// Generate LightPreInit body for mints-only (no PDAs): -/// Invoke mint_action with decompress directly -/// After this, CMint is "hot" and usable in instruction body -#[allow(clippy::too_many_arguments)] -fn generate_pre_init_mints_only( - parsed: &ParsedRentFreeStruct, - params_ident: &syn::Ident, - fee_payer: &TokenStream, - ctoken_config: &TokenStream, - ctoken_rent_sponsor: &TokenStream, - light_token_program: &TokenStream, - ctoken_cpi_authority: &TokenStream, -) -> TokenStream { - // 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 = &parsed.light_mint_fields[0]; - - // Generate mint action invocation without CPI context - let mint_invocation = generate_mint_action_invocation(&MintActionConfig { - mint, - params_ident, - fee_payer, - ctoken_config, - ctoken_rent_sponsor, - light_token_program, - ctoken_cpi_authority, - cpi_context: None, - }); - - quote! { - // Build CPI accounts (no CPI context needed for mints-only) - let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( - &self.#fee_payer, - _remaining, - crate::LIGHT_CPI_SIGNER, - ); - - // Build and invoke mint_action with decompress - #mint_invocation - - Ok(true) - } +/// Builder for PDA compression block code generation. +pub(super) struct PdaBlockBuilder<'a> { + field: &'a RentFreeField, + idents: PdaIdents, } -/// Generate LightPreInit body for PDAs only (no mints) -/// After this, compressed addresses are registered -fn generate_pre_init_pdas_only( - parsed: &ParsedRentFreeStruct, - params_ident: &syn::Ident, - fee_payer: &TokenStream, - compression_config: &TokenStream, -) -> TokenStream { - let (compress_blocks, new_addr_idents) = generate_pda_compress_blocks(&parsed.rentfree_fields); - let rentfree_count = parsed.rentfree_fields.len() as u8; - - quote! { - // Build CPI accounts (no CPI context needed for PDAs-only) - let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( - &self.#fee_payer, - _remaining, - crate::LIGHT_CPI_SIGNER, - ); - - // Load compression config - let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( - &self.#compression_config, - &crate::ID - )?; - - // Collect compressed infos for all rentfree accounts - let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); - #(#compress_blocks)* - - // Execute Light System Program CPI directly with proof - use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; - light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( - crate::LIGHT_CPI_SIGNER, - #params_ident.create_accounts_proof.proof.clone() - ) - .with_new_addresses(&[#(#new_addr_idents),*]) - .with_account_infos(&all_compressed_infos) - .invoke(cpi_accounts)?; - - Ok(true) +impl<'a> PdaBlockBuilder<'a> { + pub fn new(field: &'a RentFreeField, idx: usize) -> Self { + Self { + field, + idents: PdaIdents::new(idx), + } } -} - -/// Generate compression blocks for PDA fields -fn generate_pda_compress_blocks(fields: &[RentFreeField]) -> (Vec, Vec) { - let mut blocks = Vec::new(); - let mut addr_idents = Vec::new(); - - for (idx, field) in fields.iter().enumerate() { - let idx_lit = idx as u8; - let ident = &field.ident; - let addr_tree_info = &field.address_tree_info; - let output_tree = &field.output_tree; - let inner_type = &field.inner_type; - - let new_addr_params_ident = format_ident!("__new_addr_params_{}", idx); - let compressed_infos_ident = format_ident!("__compressed_infos_{}", idx); - let address_ident = format_ident!("__address_{}", idx); - let account_info_ident = format_ident!("__account_info_{}", idx); - let account_key_ident = format_ident!("__account_key_{}", idx); - let account_data_ident = format_ident!("__account_data_{}", idx); - // Generate correct deref pattern: ** for Box>, * for Account - let deref_expr = if field.is_boxed { - quote! { &mut **self.#ident } - } else { - quote! { &mut *self.#ident } - }; + /// Returns the identifier used for new address params (for collecting in array). + pub fn new_addr_ident(&self) -> TokenStream { + let ident = &self.idents.new_addr_params; + quote! { #ident } + } - addr_idents.push(quote! { #new_addr_params_ident }); + /// Generate account extraction (get account info and key bytes). + fn account_extraction(&self) -> TokenStream { + let ident = &self.field.ident; + let account_info = &self.idents.account_info; + let account_key = &self.idents.account_key; - blocks.push(quote! { - // Get account info early before any mutable borrows - let #account_info_ident = self.#ident.to_account_info(); - let #account_key_ident = #account_info_ident.key.to_bytes(); + quote! { + let #account_info = self.#ident.to_account_info(); + let #account_key = #account_info.key.to_bytes(); + } + } - let #new_addr_params_ident = { - let tree_info = &#addr_tree_info; + /// Generate new address params struct. + fn new_addr_params(&self) -> TokenStream { + let addr_tree_info = &self.field.address_tree_info; + let new_addr_params = &self.idents.new_addr_params; + let account_key = &self.idents.account_key; + let idx = self.idents.idx; + + quote! { + let #new_addr_params = { + // Explicit type annotation ensures clear error if wrong type is provided. + // Must be PackedAddressTreeInfo (with indices), not AddressTreeInfo (with Pubkeys). + // If you have AddressTreeInfo, pack it client-side using pack_address_tree_info(). + let tree_info: &light_sdk_types::instruction::PackedAddressTreeInfo = &#addr_tree_info; light_compressed_account::instruction_data::data::NewAddressParamsAssignedPacked { - seed: #account_key_ident, + seed: #account_key, address_merkle_tree_account_index: tree_info.address_merkle_tree_pubkey_index, address_queue_account_index: tree_info.address_queue_pubkey_index, address_merkle_tree_root_index: tree_info.root_index, assigned_to_account: true, - assigned_account_index: #idx_lit, + assigned_account_index: #idx, } }; + } + } - // Derive the compressed address - let #address_ident = light_compressed_account::address::derive_address( - &#new_addr_params_ident.seed, + /// Generate address derivation from seed and merkle tree. + fn address_derivation(&self) -> TokenStream { + let address = &self.idents.address; + let new_addr_params = &self.idents.new_addr_params; + + quote! { + let #address = light_compressed_account::address::derive_address( + &#new_addr_params.seed, &cpi_accounts - .get_tree_account_info(#new_addr_params_ident.address_merkle_tree_account_index as usize)? + .get_tree_account_info(#new_addr_params.address_merkle_tree_account_index as usize)? .key() .to_bytes(), &crate::ID.to_bytes(), ); + } + } - // Get mutable reference to inner account data - let #account_data_ident = #deref_expr; + /// Generate mutable reference to account data (handles Box vs Account). + fn account_data_extraction(&self) -> TokenStream { + let ident = &self.field.ident; + let account_data = &self.idents.account_data; - let #compressed_infos_ident = light_sdk::compressible::prepare_compressed_account_on_init::<#inner_type>( - &#account_info_ident, - #account_data_ident, + let deref_expr = if self.field.is_boxed { + quote! { &mut **self.#ident } + } else { + quote! { &mut *self.#ident } + }; + + quote! { + let #account_data = #deref_expr; + } + } + + /// Generate compression info preparation and collection. + fn compression_info(&self) -> TokenStream { + let inner_type = &self.field.inner_type; + let output_tree = &self.field.output_tree; + let account_info = &self.idents.account_info; + let account_data = &self.idents.account_data; + let address = &self.idents.address; + let new_addr_params = &self.idents.new_addr_params; + let compressed_infos = &self.idents.compressed_infos; + + quote! { + let #compressed_infos = light_sdk::compressible::prepare_compressed_account_on_init::<#inner_type>( + &#account_info, + #account_data, &compression_config_data, - #address_ident, - #new_addr_params_ident, + #address, + #new_addr_params, #output_tree, &cpi_accounts, &compression_config_data.address_space, false, // at init, we do not compress_and_close the pda, we just "register" the empty compressed account with the derived address. )?; - all_compressed_infos.push(#compressed_infos_ident); - }); + all_compressed_infos.push(#compressed_infos); + } + } + + /// Build the complete compression block for this PDA field. + pub fn build(&self) -> TokenStream { + let account_extraction = self.account_extraction(); + let new_addr_params = self.new_addr_params(); + let address_derivation = self.address_derivation(); + let account_data_extraction = self.account_data_extraction(); + let compression_info = self.compression_info(); + + quote! { + // Get account info early before any mutable borrows + #account_extraction + #new_addr_params + // Derive the compressed address + #address_derivation + // Get mutable reference to inner account data + #account_data_extraction + #compression_info + } + } +} + +/// Generate compression blocks for PDA fields using PdaBlockBuilder. +/// +/// Returns a tuple of: +/// - Vector of TokenStreams for compression blocks +/// - Vector of TokenStreams for new address parameter identifiers +pub(super) fn generate_pda_compress_blocks( + fields: &[RentFreeField], +) -> (Vec, Vec) { + let mut blocks = Vec::with_capacity(fields.len()); + let mut addr_idents = Vec::with_capacity(fields.len()); + + for (idx, field) in fields.iter().enumerate() { + let builder = PdaBlockBuilder::new(field, idx); + addr_idents.push(builder.new_addr_ident()); + blocks.push(builder.build()); } (blocks, addr_idents) diff --git a/sdk-libs/macros/src/rentfree/program/compress.rs b/sdk-libs/macros/src/rentfree/program/compress.rs index 02c180c5b4..7df3703315 100644 --- a/sdk-libs/macros/src/rentfree/program/compress.rs +++ b/sdk-libs/macros/src/rentfree/program/compress.rs @@ -2,18 +2,21 @@ use proc_macro2::TokenStream; use quote::quote; -use syn::{Ident, Result}; +use syn::{Result, Type}; use super::parsing::InstructionVariant; +use crate::rentfree::shared_utils::qualify_type_with_crate; // ============================================================================= // COMPRESS CONTEXT IMPL // ============================================================================= -pub fn generate_compress_context_impl(account_types: Vec) -> Result { +pub fn generate_compress_context_impl(account_types: Vec) -> Result { let lifetime: syn::Lifetime = syn::parse_quote!('info); - let compress_arms: Vec<_> = account_types.iter().map(|name| { + let compress_arms: Vec<_> = account_types.iter().map(|account_type| { + // Qualify with crate:: to ensure it's accessible from generated code + let name = qualify_type_with_crate(account_type); quote! { d if d == #name::LIGHT_DISCRIMINATOR => { drop(data); @@ -175,14 +178,16 @@ pub fn generate_compress_accounts_struct(variant: InstructionVariant) -> Result< // ============================================================================= #[inline(never)] -pub fn validate_compressed_account_sizes(account_types: &[Ident]) -> Result { +pub fn validate_compressed_account_sizes(account_types: &[Type]) -> Result { let size_checks: Vec<_> = account_types.iter().map(|account_type| { + // Qualify with crate:: to ensure it's accessible from generated code + let qualified_type = qualify_type_with_crate(account_type); quote! { const _: () = { - const COMPRESSED_SIZE: usize = 8 + <#account_type as light_sdk::compressible::compression_info::CompressedInitSpace>::COMPRESSED_INIT_SPACE; + const COMPRESSED_SIZE: usize = 8 + <#qualified_type as light_sdk::compressible::compression_info::CompressedInitSpace>::COMPRESSED_INIT_SPACE; if COMPRESSED_SIZE > 800 { panic!(concat!( - "Compressed account '", stringify!(#account_type), "' exceeds 800-byte compressible account size limit. If you need support for larger accounts, send a message to team@lightprotocol.com" + "Compressed account '", stringify!(#qualified_type), "' exceeds 800-byte compressible account size limit. If you need support for larger accounts, send a message to team@lightprotocol.com" )); } }; diff --git a/sdk-libs/macros/src/rentfree/program/decompress.rs b/sdk-libs/macros/src/rentfree/program/decompress.rs index e39768a6c0..f9f129820d 100644 --- a/sdk-libs/macros/src/rentfree/program/decompress.rs +++ b/sdk-libs/macros/src/rentfree/program/decompress.rs @@ -10,7 +10,7 @@ use super::{ seed_utils::ctx_fields_to_set, variant_enum::PdaCtxSeedInfo, }; -use crate::rentfree::shared_utils::is_constant_identifier; +use crate::rentfree::shared_utils::{is_constant_identifier, qualify_type_with_crate}; // ============================================================================= // DECOMPRESS CONTEXT IMPL @@ -228,36 +228,41 @@ fn generate_pda_seed_derivation_for_trait_with_ctx_seeds( #[inline(never)] pub fn generate_pda_seed_provider_impls( - account_types: &[Ident], + account_types: &[syn::Type], pda_ctx_seeds: &[PdaCtxSeedInfo], pda_seeds: &Option>, ) -> Result> { let pda_seed_specs = pda_seeds.as_ref().ok_or_else(|| { - super::parsing::macro_error!( - account_types - .first() - .cloned() - .unwrap_or_else(|| syn::Ident::new("unknown", proc_macro2::Span::call_site())), - "No seed specifications provided" - ) + // Use first account type for error span, or create a dummy span + let span_source = account_types + .first() + .map(|t| quote::quote!(#t)) + .unwrap_or_else(|| quote::quote!(unknown)); + super::parsing::macro_error!(span_source, "No seed specifications provided") })?; - let mut results = Vec::with_capacity(account_types.len()); + let mut results = Vec::with_capacity(pda_ctx_seeds.len()); - for (name, ctx_info) in account_types.iter().zip(pda_ctx_seeds.iter()) { - let name_str = name.to_string(); + // Iterate over pda_ctx_seeds which has both variant_name and inner_type + for ctx_info in pda_ctx_seeds.iter() { + // Match spec by variant_name (field name based) + let variant_str = ctx_info.variant_name.to_string(); let spec = pda_seed_specs .iter() - .find(|s| s.variant == name_str) + .find(|s| s.variant == variant_str) .ok_or_else(|| { super::parsing::macro_error!( - name, - "No seed specification for account type '{}'", - name_str + &ctx_info.variant_name, + "No seed specification for variant '{}'", + variant_str ) })?; - let ctx_seeds_struct_name = format_ident!("{}CtxSeeds", name); + // Use variant_name for struct naming (e.g., RecordCtxSeeds) + let ctx_seeds_struct_name = format_ident!("{}CtxSeeds", ctx_info.variant_name); + // Use inner_type for the impl (e.g., impl ... for crate::SinglePubkeyRecord) + // Qualify with crate:: to ensure it's accessible from generated code + let inner_type = qualify_type_with_crate(&ctx_info.inner_type); let ctx_fields = &ctx_info.ctx_seed_fields; let ctx_fields_decl: Vec<_> = ctx_fields .iter() @@ -283,10 +288,11 @@ pub fn generate_pda_seed_provider_impls( let seed_derivation = generate_pda_seed_derivation_for_trait_with_ctx_seeds(spec, ctx_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 #name { + impl light_sdk::compressible::PdaSeedDerivation<#ctx_seeds_struct_name, ()> for #inner_type { fn derive_pda_seeds_with_accounts( &self, program_id: &solana_pubkey::Pubkey, diff --git a/sdk-libs/macros/src/rentfree/program/instructions.rs b/sdk-libs/macros/src/rentfree/program/instructions.rs index 704635cc50..87af7c562b 100644 --- a/sdk-libs/macros/src/rentfree/program/instructions.rs +++ b/sdk-libs/macros/src/rentfree/program/instructions.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Ident, Item, ItemMod, Result}; +use syn::{Item, ItemMod, Result, Type}; // Re-export types from parsing for external use pub use super::parsing::{ @@ -26,7 +26,10 @@ use super::{ }, variant_enum::PdaCtxSeedInfo, }; -use crate::utils::to_snake_case; +use crate::{ + rentfree::shared_utils::{ident_to_type, qualify_type_with_crate}, + utils::to_snake_case, +}; // ============================================================================= // MAIN CODEGEN @@ -36,7 +39,7 @@ use crate::utils::to_snake_case; #[inline(never)] fn codegen( module: &mut ItemMod, - account_types: Vec, + account_types: Vec, pda_seeds: Option>, token_seeds: Option>, instruction_data: Vec, @@ -44,6 +47,14 @@ fn codegen( let size_validation_checks = validate_compressed_account_sizes(&account_types)?; let content = module.content.as_mut().unwrap(); + + // Insert anchor_lang::prelude::* import at the beginning of the module + // This ensures Accounts, Signer, AccountInfo, Result, error_code etc. are in scope + // for the generated code (structs, enums, functions). + let anchor_import: syn::Item = syn::parse_quote! { + use anchor_lang::prelude::*; + }; + content.1.insert(0, anchor_import); let ctoken_enum = if let Some(ref token_seed_specs) = token_seeds { if !token_seed_specs.is_empty() { super::variant_enum::generate_ctoken_account_variant_enum(token_seed_specs)? @@ -73,17 +84,19 @@ fn codegen( .iter() .map(|spec| { let ctx_fields = extract_ctx_seed_fields(&spec.seeds); - PdaCtxSeedInfo::new(spec.variant.clone(), ctx_fields) + // Use inner_type if available (from #[rentfree] fields), otherwise fall back to variant as type + let inner_type = spec + .inner_type + .clone() + .unwrap_or_else(|| ident_to_type(&spec.variant)); + PdaCtxSeedInfo::new(spec.variant.clone(), inner_type, ctx_fields) }) .collect() }) .unwrap_or_default(); - let account_type_refs: Vec<&Ident> = account_types.iter().collect(); - let enum_and_traits = super::variant_enum::compressed_account_variant_with_ctx_seeds( - &account_type_refs, - &pda_ctx_seeds, - )?; + 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)] @@ -102,9 +115,12 @@ fn codegen( .iter() .zip(pda_ctx_seeds.iter()) .map(|(spec, ctx_info)| { - let type_name = &spec.variant; - let seeds_struct_name = format_ident!("{}Seeds", type_name); - let constructor_name = format_ident!("{}", to_snake_case(&type_name.to_string())); + // Use variant_name for naming (struct, constructor, enum variant) + let variant_name = &ctx_info.variant_name; + // Use inner_type for deserialization - qualify with crate:: for accessibility + let inner_type = qualify_type_with_crate(&ctx_info.inner_type); + 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 ctx_field_decls: Vec<_> = ctx_fields.iter().map(|field| { quote! { pub #field: solana_pubkey::Pubkey } @@ -135,11 +151,13 @@ fn codegen( seeds: #seeds_struct_name, ) -> std::result::Result { use anchor_lang::AnchorDeserialize; - let data = #type_name::deserialize(&mut &account_data[..])?; + // Deserialize using inner_type + let data = #inner_type::deserialize(&mut &account_data[..])?; #(#data_verifications)* - std::result::Result::Ok(Self::#type_name { + // Use variant_name for the enum variant + std::result::Result::Ok(Self::#variant_name { data, #(#ctx_fields: seeds.#ctx_fields,)* }) @@ -293,7 +311,6 @@ fn codegen( }; let client_functions = super::seed_codegen::generate_client_seed_functions( - &account_types, &pda_seeds, &token_seeds, &instruction_data, @@ -439,11 +456,20 @@ pub fn rentfree_program_impl(_args: TokenStream, mut module: ItemMod) -> Result< } // Convert extracted specs to the format expected by codegen + // Deduplicate based on variant_name (field name) - field names must be globally unique let mut found_pda_seeds: Vec = Vec::new(); let mut found_data_fields: Vec = Vec::new(); - let mut account_types: Vec = Vec::new(); + let mut account_types: Vec = Vec::new(); + let mut seen_variants: std::collections::HashSet = std::collections::HashSet::new(); for pda in &pda_specs { + // Deduplicate based on variant_name (derived from field name) + // If same field name is used in multiple instruction structs, only add once + let variant_str = pda.variant_name.to_string(); + if !seen_variants.insert(variant_str) { + continue; // Skip duplicate field names + } + account_types.push(pda.inner_type.clone()); let seed_elements = convert_classified_to_seed_elements(&pda.seeds); @@ -465,11 +491,14 @@ pub fn rentfree_program_impl(_args: TokenStream, mut module: ItemMod) -> Result< } found_pda_seeds.push(TokenSeedSpec { + // Use variant_name (from field name) for enum variant naming variant: pda.variant_name.clone(), _eq: syn::parse_quote!(=), is_token: Some(false), seeds: seed_elements, authority: None, + // Store inner_type for type references (deserialization, trait bounds) + inner_type: Some(pda.inner_type.clone()), }); } @@ -488,6 +517,7 @@ pub fn rentfree_program_impl(_args: TokenStream, mut module: ItemMod) -> Result< is_token: Some(true), seeds: seed_elements, authority: authority_elements, + inner_type: None, // Token specs don't have inner type }); } diff --git a/sdk-libs/macros/src/rentfree/program/parsing.rs b/sdk-libs/macros/src/rentfree/program/parsing.rs index 6100fa3d95..c5cf063aa8 100644 --- a/sdk-libs/macros/src/rentfree/program/parsing.rs +++ b/sdk-libs/macros/src/rentfree/program/parsing.rs @@ -53,11 +53,16 @@ pub enum InstructionVariant { #[derive(Clone)] pub struct TokenSeedSpec { + /// The variant name (derived from field name, used for enum variant naming) pub variant: Ident, pub _eq: Token![=], pub is_token: Option, pub seeds: Punctuated, pub authority: Option>, + /// The inner type (e.g., crate::state::SinglePubkeyRecord - used for type references) + /// Preserves the full type path for code generation. + /// Only set for PDAs extracted from #[rentfree] fields; None for parsed specs + pub inner_type: Option, } impl Parse for TokenSeedSpec { @@ -141,6 +146,7 @@ impl Parse for TokenSeedSpec { is_token, seeds, authority, + inner_type: None, // Set by caller for #[rentfree] fields }) } } @@ -404,25 +410,24 @@ pub fn wrap_function_with_rentfree(fn_item: &ItemFn, params_ident: &Ident) -> It #fn_vis #fn_sig { // Phase 1: Pre-init (creates mints via CPI context write, registers compressed addresses) use light_sdk::compressible::{LightPreInit, LightFinalize}; - let __has_pre_init = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) + let _ = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) .map_err(|e| { let pe: solana_program_error::ProgramError = e.into(); pe })?; - // Execute the original handler body in a closure - let __light_handler_result = (|| #fn_block)(); - - // Phase 2: On success, finalize compression - if __light_handler_result.is_ok() { - ctx.accounts.light_finalize(ctx.remaining_accounts, &#params_ident, __has_pre_init) - .map_err(|e| { - let pe: solana_program_error::ProgramError = e.into(); - pe - })?; - } - - __light_handler_result + // Execute the original handler body + #fn_block + + // TODO(diff-pr): Reactivate light_finalize for top up transfers. + // Currently disabled because user code may move ctx, making it + // inaccessible after the handler body executes. When top up + // transfers are implemented, we'll need to store AccountInfo + // references before user code runs. + // + // if __light_handler_result.is_ok() { + // ctx.accounts.light_finalize(ctx.remaining_accounts, ¶ms, __has_pre_init)?; + // } } }; diff --git a/sdk-libs/macros/src/rentfree/program/seed_codegen.rs b/sdk-libs/macros/src/rentfree/program/seed_codegen.rs index 121a2e73f6..10dc4451d2 100644 --- a/sdk-libs/macros/src/rentfree/program/seed_codegen.rs +++ b/sdk-libs/macros/src/rentfree/program/seed_codegen.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Ident, Result}; +use syn::Result; use super::{ instructions::{InstructionDataSpec, TokenSeedSpec}, @@ -109,7 +109,6 @@ pub fn generate_ctoken_seed_provider_implementation( #[inline(never)] pub fn generate_client_seed_functions( - _account_types: &[Ident], pda_seeds: &Option>, token_seeds: &Option>, instruction_data: &[InstructionDataSpec], @@ -165,6 +164,7 @@ pub fn generate_client_seed_functions( is_token: spec.is_token, seeds: syn::punctuated::Punctuated::new(), authority: None, + inner_type: spec.inner_type.clone(), }; for auth_seed in authority_seeds { diff --git a/sdk-libs/macros/src/rentfree/program/variant_enum.rs b/sdk-libs/macros/src/rentfree/program/variant_enum.rs index be3d109e90..d0d5659d15 100644 --- a/sdk-libs/macros/src/rentfree/program/variant_enum.rs +++ b/sdk-libs/macros/src/rentfree/program/variant_enum.rs @@ -1,48 +1,56 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Ident, Result}; +use syn::{Ident, Result, Type}; use super::parsing::{SeedElement, TokenSeedSpec}; +use crate::rentfree::shared_utils::{ + make_packed_type, make_packed_variant_name, qualify_type_with_crate, +}; /// Info about ctx.* seeds for a PDA type #[derive(Clone, Debug)] pub struct PdaCtxSeedInfo { - pub type_name: Ident, + /// The variant name (derived from field name, e.g., "Record" from field "record") + pub variant_name: Ident, + /// The inner type (e.g., crate::state::SinglePubkeyRecord - preserves full path) + pub inner_type: Type, /// Field names from ctx.accounts.XXX references in seeds pub ctx_seed_fields: Vec, } impl PdaCtxSeedInfo { - pub fn new(type_name: Ident, ctx_seed_fields: Vec) -> Self { + pub fn new(variant_name: Ident, inner_type: Type, ctx_seed_fields: Vec) -> Self { Self { - type_name, + variant_name, + inner_type, ctx_seed_fields, } } } -/// Enhanced function that generates variants with ctx.* seed fields +/// Enhanced function that generates variants with ctx.* seed fields. +/// Now uses variant_name for enum variant naming and inner_type for type references. pub fn compressed_account_variant_with_ctx_seeds( - account_types: &[&Ident], pda_ctx_seeds: &[PdaCtxSeedInfo], ) -> Result { - if account_types.is_empty() { + if pda_ctx_seeds.is_empty() { return Err(syn::Error::new( proc_macro2::Span::call_site(), "At least one account type must be specified", )); } - // Build a map from type name to ctx seed fields - let ctx_seeds_map: std::collections::HashMap = pda_ctx_seeds - .iter() - .map(|info| (info.type_name.to_string(), info.ctx_seed_fields.as_slice())) - .collect(); - // Phase 2: Generate struct variants with ctx.* seed fields - let account_variants = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); - let ctx_fields = ctx_seeds_map.get(&name.to_string()).copied().unwrap_or(&[]); + // 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; + // Qualify inner_type with crate:: to ensure it's accessible from generated code + let inner_type = qualify_type_with_crate(&info.inner_type); + let packed_variant_name = make_packed_variant_name(variant_name); + // Create packed type (also qualified with crate::) + 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; // Unpacked variant: Pubkey fields for ctx.* seeds // Note: Use bare Pubkey which is in scope via `use anchor_lang::prelude::*` @@ -57,8 +65,8 @@ pub fn compressed_account_variant_with_ctx_seeds( }); quote! { - #name { data: #name, #(#unpacked_ctx_fields,)* }, - #packed_name { data: #packed_name, #(#packed_ctx_fields,)* }, + #variant_name { data: #inner_type, #(#unpacked_ctx_fields,)* }, + #packed_variant_name { data: #packed_inner_type, #(#packed_ctx_fields,)* }, } }); @@ -73,27 +81,28 @@ pub fn compressed_account_variant_with_ctx_seeds( } }; - let first_type = account_types[0]; - let first_ctx_fields = ctx_seeds_map - .get(&first_type.to_string()) - .copied() - .unwrap_or(&[]); + let first = &pda_ctx_seeds[0]; + 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_default_ctx_fields = first_ctx_fields.iter().map(|field| { quote! { #field: Pubkey::default() } }); let default_impl = quote! { impl Default for RentFreeAccountVariant { fn default() -> Self { - Self::#first_type { data: #first_type::default(), #(#first_default_ctx_fields,)* } + Self::#first_variant { data: #first_type::default(), #(#first_default_ctx_fields,)* } } } }; - let hash_match_arms = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); + let hash_match_arms = 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); quote! { - RentFreeAccountVariant::#name { data, .. } => <#name as light_hasher::DataHasher>::hash::(data), - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, .. } => <#inner_type as light_hasher::DataHasher>::hash::(data), + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), } }); @@ -116,35 +125,43 @@ pub fn compressed_account_variant_with_ctx_seeds( } }; - let compression_info_match_arms = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); + let compression_info_match_arms = 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); quote! { - RentFreeAccountVariant::#name { data, .. } => <#name as light_sdk::compressible::HasCompressionInfo>::compression_info(data), - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::compressible::HasCompressionInfo>::compression_info(data), + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), } }); - let compression_info_mut_match_arms = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); + let compression_info_mut_match_arms = 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); quote! { - RentFreeAccountVariant::#name { data, .. } => <#name as light_sdk::compressible::HasCompressionInfo>::compression_info_mut(data), - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::compressible::HasCompressionInfo>::compression_info_mut(data), + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), } }); - let compression_info_mut_opt_match_arms = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); + let compression_info_mut_opt_match_arms = 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); quote! { - RentFreeAccountVariant::#name { data, .. } => <#name as light_sdk::compressible::HasCompressionInfo>::compression_info_mut_opt(data), - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::compressible::HasCompressionInfo>::compression_info_mut_opt(data), + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), } }); - let set_compression_info_none_match_arms = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); + let set_compression_info_none_match_arms = 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); quote! { - RentFreeAccountVariant::#name { data, .. } => <#name as light_sdk::compressible::HasCompressionInfo>::set_compression_info_none(data), - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::compressible::HasCompressionInfo>::set_compression_info_none(data), + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), } }); @@ -184,11 +201,13 @@ pub fn compressed_account_variant_with_ctx_seeds( } }; - let size_match_arms = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); + let size_match_arms = 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); quote! { - RentFreeAccountVariant::#name { data, .. } => <#name as light_sdk::account::Size>::size(data), - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::account::Size>::size(data), + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), } }); @@ -205,16 +224,18 @@ pub fn compressed_account_variant_with_ctx_seeds( }; // Phase 2: Pack/Unpack with ctx seed fields - let pack_match_arms: Vec<_> = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); - let ctx_fields = ctx_seeds_map.get(&name.to_string()).copied().unwrap_or(&[]); + 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; if ctx_fields.is_empty() { // No ctx seeds - simple pack quote! { - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), - RentFreeAccountVariant::#name { data, .. } => RentFreeAccountVariant::#packed_name { - data: <#name as light_sdk::compressible::Pack>::pack(data, remaining_accounts), + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, .. } => RentFreeAccountVariant::#packed_variant_name { + data: <#inner_type as light_sdk::compressible::Pack>::pack(data, remaining_accounts), }, } } else { @@ -228,11 +249,11 @@ pub fn compressed_account_variant_with_ctx_seeds( }).collect(); quote! { - RentFreeAccountVariant::#packed_name { .. } => unreachable!(), - RentFreeAccountVariant::#name { data, #(#field_names,)* .. } => { + RentFreeAccountVariant::#packed_variant_name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { data, #(#field_names,)* .. } => { #(#pack_ctx_seeds)* - RentFreeAccountVariant::#packed_name { - data: <#name as light_sdk::compressible::Pack>::pack(data, remaining_accounts), + RentFreeAccountVariant::#packed_variant_name { + data: <#inner_type as light_sdk::compressible::Pack>::pack(data, remaining_accounts), #(#idx_field_names,)* } }, @@ -257,17 +278,22 @@ pub fn compressed_account_variant_with_ctx_seeds( } }; - let unpack_match_arms: Vec<_> = account_types.iter().map(|name| { - let packed_name = format_ident!("Packed{}", name); - let ctx_fields = ctx_seeds_map.get(&name.to_string()).copied().unwrap_or(&[]); + let unpack_match_arms: Vec<_> = pda_ctx_seeds.iter().map(|info| { + let variant_name = &info.variant_name; + let inner_type = &info.inner_type; + let packed_variant_name = make_packed_variant_name(variant_name); + // Create packed type preserving full path (e.g., crate::module::PackedMyRecord) + 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; if ctx_fields.is_empty() { // No ctx seeds - simple unpack quote! { - RentFreeAccountVariant::#packed_name { data, .. } => Ok(RentFreeAccountVariant::#name { - data: <#packed_name as light_sdk::compressible::Unpack>::unpack(data, remaining_accounts)?, + RentFreeAccountVariant::#packed_variant_name { data, .. } => Ok(RentFreeAccountVariant::#variant_name { + data: <#packed_inner_type as light_sdk::compressible::Unpack>::unpack(data, remaining_accounts)?, }), - RentFreeAccountVariant::#name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { .. } => unreachable!(), } } else { // Has ctx seeds - unpack data and resolve ctx seed pubkeys from indices @@ -284,14 +310,14 @@ pub fn compressed_account_variant_with_ctx_seeds( }).collect(); quote! { - RentFreeAccountVariant::#packed_name { data, #(#idx_field_names,)* .. } => { + RentFreeAccountVariant::#packed_variant_name { data, #(#idx_field_names,)* .. } => { #(#unpack_ctx_seeds)* - Ok(RentFreeAccountVariant::#name { - data: <#packed_name as light_sdk::compressible::Unpack>::unpack(data, remaining_accounts)?, + Ok(RentFreeAccountVariant::#variant_name { + data: <#packed_inner_type as light_sdk::compressible::Unpack>::unpack(data, remaining_accounts)?, #(#field_names,)* }) }, - RentFreeAccountVariant::#name { .. } => unreachable!(), + RentFreeAccountVariant::#variant_name { .. } => unreachable!(), } } }).collect(); diff --git a/sdk-libs/macros/src/rentfree/shared_utils.rs b/sdk-libs/macros/src/rentfree/shared_utils.rs index a9550b5adf..91dde17a28 100644 --- a/sdk-libs/macros/src/rentfree/shared_utils.rs +++ b/sdk-libs/macros/src/rentfree/shared_utils.rs @@ -3,8 +3,103 @@ //! This module provides common utility functions used across multiple files: //! - Constant identifier detection (SCREAMING_SNAKE_CASE) //! - Expression identifier extraction +//! - MetaExpr for darling attribute parsing -use syn::{Expr, Ident}; +use darling::FromMeta; +use quote::format_ident; +use syn::{Expr, Ident, Type}; + +// ============================================================================ +// Type path helpers for preserving full type paths in code generation +// ============================================================================ + +/// Ensures a type path is fully qualified with `crate::` prefix. +/// For types that are already qualified (crate::, super::, self::, or absolute ::), +/// returns them unchanged. For bare types like `MyRecord`, returns `crate::MyRecord`. +/// +/// This ensures generated code can reference types regardless of what imports +/// are in scope at the generation site. +pub fn qualify_type_with_crate(ty: &Type) -> Type { + if let Type::Path(type_path) = ty { + // Check if already qualified + if let Some(first_seg) = type_path.path.segments.first() { + let first_str = first_seg.ident.to_string(); + // Already qualified with crate, super, self, or starts with :: + if first_str == "crate" || first_str == "super" || first_str == "self" { + return ty.clone(); + } + } + // Check for absolute path (starts with ::) + if type_path.path.leading_colon.is_some() { + return ty.clone(); + } + + // Prepend crate:: to the path + let mut qualified_path = type_path.clone(); + let crate_segment: syn::PathSegment = syn::parse_quote!(crate); + qualified_path.path.segments.insert(0, crate_segment); + Type::Path(qualified_path) + } else { + ty.clone() + } +} + +/// Creates a packed type path from an original type. +/// For `crate::module::MyRecord` returns `crate::module::PackedMyRecord` +/// For `MyRecord` returns `crate::PackedMyRecord` (qualified and packed) +/// +/// First qualifies the type with `crate::`, then prepends "Packed" to the terminal type name. +pub fn make_packed_type(ty: &Type) -> Option { + // First qualify the type + let qualified = qualify_type_with_crate(ty); + + if let Type::Path(type_path) = &qualified { + let mut packed_path = type_path.clone(); + if let Some(last_seg) = packed_path.path.segments.last_mut() { + let packed_name = format_ident!("Packed{}", last_seg.ident); + last_seg.ident = packed_name; + } + Some(Type::Path(packed_path)) + } else { + None + } +} + +/// Creates a packed variant name (Ident) from a variant name. +/// For `Record` returns `PackedRecord` +pub fn make_packed_variant_name(variant_name: &Ident) -> Ident { + format_ident!("Packed{}", variant_name) +} + +/// Creates a simple type from an identifier (for cases where we only have variant name). +/// Converts `MyRecord` Ident to `MyRecord` Type. +pub fn ident_to_type(ident: &Ident) -> Type { + let path: syn::Path = ident.clone().into(); + Type::Path(syn::TypePath { qself: None, path }) +} + +// ============================================================================ +// darling support for parsing Expr from attributes +// ============================================================================ + +/// Wrapper for syn::Expr that implements darling's FromMeta trait. +/// +/// Enables darling to parse arbitrary expressions in attributes like +/// `#[light_mint(mint_signer = self.authority)]`. +#[derive(Clone)] +pub struct MetaExpr(Expr); + +impl FromMeta for MetaExpr { + fn from_expr(expr: &Expr) -> darling::Result { + Ok(MetaExpr(expr.clone())) + } +} + +impl From for Expr { + fn from(meta: MetaExpr) -> Expr { + meta.0 + } +} /// Check if an identifier string is a constant (SCREAMING_SNAKE_CASE). /// diff --git a/sdk-libs/macros/src/rentfree/traits/decompress_context.rs b/sdk-libs/macros/src/rentfree/traits/decompress_context.rs index 44690279f5..e7680cb71e 100644 --- a/sdk-libs/macros/src/rentfree/traits/decompress_context.rs +++ b/sdk-libs/macros/src/rentfree/traits/decompress_context.rs @@ -6,6 +6,9 @@ use syn::{Ident, Result}; // Re-export from variant_enum for convenience pub use crate::rentfree::program::variant_enum::PdaCtxSeedInfo; +use crate::rentfree::shared_utils::{ + make_packed_type, make_packed_variant_name, qualify_type_with_crate, +}; pub fn generate_decompress_context_trait_impl( pda_ctx_seeds: Vec, @@ -16,9 +19,17 @@ pub fn generate_decompress_context_trait_impl( let pda_match_arms: Vec<_> = pda_ctx_seeds .iter() .map(|info| { - let pda_type = &info.type_name; - let packed_name = format_ident!("Packed{}", pda_type); - let ctx_seeds_struct_name = format_ident!("{}CtxSeeds", pda_type); + // Use variant_name for enum variant matching + let variant_name = &info.variant_name; + // Use inner_type for type references (generics, trait bounds) + // Qualify with crate:: to ensure it's accessible from generated code + let inner_type = qualify_type_with_crate(&info.inner_type); + let packed_variant_name = make_packed_variant_name(variant_name); + // Create packed type (also qualified with crate::) + let packed_inner_type = make_packed_type(&info.inner_type) + .expect("inner_type should be a valid type path"); + // 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; // Generate pattern to extract idx fields from packed variant let idx_field_patterns: Vec<_> = ctx_fields.iter().map(|field| { @@ -46,9 +57,9 @@ pub fn generate_decompress_context_trait_impl( }; if ctx_fields.is_empty() { quote! { - RentFreeAccountVariant::#packed_name { data: packed, .. } => { + RentFreeAccountVariant::#packed_variant_name { data: packed, .. } => { #ctx_seeds_construction - match light_sdk::compressible::handle_packed_pda_variant::<#pda_type, #packed_name, _, _>( + match light_sdk::compressible::handle_packed_pda_variant::<#inner_type, #packed_inner_type, _, _>( &*self.rent_sponsor, cpi_accounts, address_space, @@ -66,16 +77,16 @@ pub fn generate_decompress_context_trait_impl( std::result::Result::Err(e) => return std::result::Result::Err(e), } } - RentFreeAccountVariant::#pda_type { .. } => { + RentFreeAccountVariant::#variant_name { .. } => { unreachable!("Unpacked variants should not be present during decompression"); } } } else { quote! { - RentFreeAccountVariant::#packed_name { data: packed, #(#idx_field_patterns,)* .. } => { + RentFreeAccountVariant::#packed_variant_name { data: packed, #(#idx_field_patterns,)* .. } => { #(#resolve_ctx_seeds)* #ctx_seeds_construction - match light_sdk::compressible::handle_packed_pda_variant::<#pda_type, #packed_name, _, _>( + match light_sdk::compressible::handle_packed_pda_variant::<#inner_type, #packed_inner_type, _, _>( &*self.rent_sponsor, cpi_accounts, address_space, @@ -93,7 +104,7 @@ pub fn generate_decompress_context_trait_impl( std::result::Result::Err(e) => return std::result::Result::Err(e), } } - RentFreeAccountVariant::#pda_type { .. } => { + RentFreeAccountVariant::#variant_name { .. } => { unreachable!("Unpacked variants should not be present during decompression"); } } diff --git a/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs b/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs index 143c7958e7..104b055a50 100644 --- a/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs +++ b/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs @@ -37,9 +37,13 @@ pub enum ClassifiedSeed { #[derive(Clone, Debug)] pub struct ExtractedSeedSpec { /// The variant name derived from field_name (snake_case -> CamelCase) + /// Note: Currently unused as we use inner_type for seed spec correlation, + /// but kept for potential future use cases (e.g., custom variant naming). + #[allow(dead_code)] pub variant_name: Ident, - /// The inner type (e.g., UserRecord from Account<'info, UserRecord>) - pub inner_type: Ident, + /// The inner type (e.g., crate::state::UserRecord from Account<'info, UserRecord>) + /// Preserves the full type path for code generation. + pub inner_type: Type, /// Classified seeds from #[account(seeds = [...])] pub seeds: Vec, } @@ -300,7 +304,10 @@ fn parse_rentfree_token_list(tokens: &proc_macro2::TokenStream) -> syn::Result, Box>, /// AccountLoader<'info, T>, or InterfaceAccount<'info, T> -pub fn extract_account_inner_type(ty: &Type) -> Option<(bool, Ident)> { +/// +/// Returns the full type path (e.g., `crate::module::MyRecord`) to preserve +/// module qualification for code generation. +pub fn extract_account_inner_type(ty: &Type) -> Option<(bool, Type)> { match ty { Type::Path(type_path) => { let segment = type_path.path.segments.last()?; @@ -311,11 +318,15 @@ pub fn extract_account_inner_type(ty: &Type) -> Option<(bool, Ident)> { // Extract T from Account<'info, T> if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { for arg in &args.args { - if let syn::GenericArgument::Type(Type::Path(inner_path)) = arg { - if let Some(inner_seg) = inner_path.path.segments.last() { - // Skip lifetime 'info - if inner_seg.ident != "info" { - return Some((false, inner_seg.ident.clone())); + if let syn::GenericArgument::Type(inner_ty) = arg { + // Skip lifetime 'info by checking if this is a path type + if let Type::Path(inner_path) = inner_ty { + if let Some(inner_seg) = inner_path.path.segments.last() { + // Skip lifetime 'info TODO: add a helper that is generalized to strip lifetimes or check whether a crate already has this + if inner_seg.ident != "info" { + // Return the full type, preserving the path + return Some((false, inner_ty.clone())); + } } } } diff --git a/sdk-libs/macros/tests/discriminator.rs b/sdk-libs/macros/tests/discriminator.rs deleted file mode 100644 index a7c70fdfec..0000000000 --- a/sdk-libs/macros/tests/discriminator.rs +++ /dev/null @@ -1,16 +0,0 @@ -use light_account_checks::discriminator::Discriminator as LightDiscriminator; -use light_sdk_macros::LightDiscriminator; - -#[test] -fn test_anchor_discriminator() { - #[cfg(feature = "anchor-discriminator")] - let protocol_config_discriminator = &[96, 176, 239, 146, 1, 254, 99, 146]; - #[cfg(not(feature = "anchor-discriminator"))] - let protocol_config_discriminator = &[254, 235, 147, 47, 205, 77, 97, 201]; - #[derive(LightDiscriminator)] - pub struct ProtocolConfigPda {} - assert_eq!( - protocol_config_discriminator, - &ProtocolConfigPda::LIGHT_DISCRIMINATOR - ); -} diff --git a/sdk-libs/macros/tests/hasher.rs b/sdk-libs/macros/tests/hasher.rs deleted file mode 100644 index 1154569be6..0000000000 --- a/sdk-libs/macros/tests/hasher.rs +++ /dev/null @@ -1,1536 +0,0 @@ -use std::{cell::RefCell, marker::PhantomData, rc::Rc}; - -use borsh::{BorshDeserialize, BorshSerialize}; -use light_compressed_account::hash_to_bn254_field_size_be; -use light_hasher::{to_byte_array::ToByteArray, DataHasher, Hasher, Poseidon, Sha256}; -use light_sdk_macros::LightHasher; -use solana_pubkey::Pubkey; - -#[derive(LightHasher, Clone)] -pub struct MyAccount { - pub a: bool, - pub b: u64, - pub c: MyNestedStruct, - #[hash] - pub d: [u8; 32], - pub f: Option, -} - -#[derive(LightHasher, Clone)] -pub struct TruncateVec { - #[hash] - pub d: Vec, -} - -#[derive(LightHasher, Clone)] -pub struct MyNestedStruct { - pub a: i32, - pub b: u32, - #[hash] - pub c: String, -} - -#[derive(Clone)] -pub struct MyNestedNonHashableStruct { - pub a: PhantomData<()>, - pub b: Rc>, -} - -#[test] -fn test_simple_hash() { - let account = MyAccount { - a: true, - b: 42, - c: MyNestedStruct { - a: 100, - b: 200, - c: "test".to_string(), - }, - d: [1u8; 32], - f: Some(10), - }; - - // Simply test that hashing works - let result = account.hash::(); - assert!(result.is_ok()); - - // Test ToByteArray and to_byte_arrays - let bytes = account.to_byte_array(); - assert!(bytes.is_ok()); -} -// #[cfg(test)] -// mod tests { - -/// LightHasher Tests -/// -/// 1. Basic Hashing (Success): -/// - test_byte_representation: assert_eq! nested struct hash matches manual hash -/// - test_zero_values: assert_eq! zero-value field hash matches manual hash -/// -/// 2. Attribute Behavior: -/// a. HashToFieldSize (Success): -/// - test_array_truncation: assert_ne! between different array hashes -/// - test_truncation_longer_array: assert_ne! between different long string hashes -/// - test_multiple_truncates: assert_ne! between multiple truncated field hashes -/// - test_nested_with_truncate: assert_eq! nested + truncated field hash matches manual hash -/// -/// b. Nested (Success): -/// - test_recursive_nesting: assert_eq! recursive nested struct hash matches manual hash -/// - test_nested_option: assert_eq! Option hash matches manual hash -/// - test_nested_field_count: assert!(is_ok()) with 12 nested fields -/// -/// 3. Error Cases (Failure): -/// - test_empty_struct: assert!(is_err()) on empty struct -/// - test_poseidon_width_limits: assert!(is_err()) with >12 fields -/// - test_max_array_length: assert!(is_err()) on array exceeding max size -/// - test_option_array_error: assert!(is_err()) on Option<[u8;32]> without truncate -/// -/// 4. Option Handling (Success): -/// - test_option_hashing_with_reference_values: assert_eq! against reference hashes -/// - test_basic_option_variants: assert_eq! basic type hashes match manual hash -/// - test_truncated_option_variants: assert_eq! truncated Option hash matches manual hash -/// - test_nested_option_variants: assert_eq! nested Option hash matches manual hash -/// - test_mixed_option_combinations: assert_eq! combined Option hash matches manual hash -/// - test_nested_struct_with_options: assert_eq! nested struct with options hash matches manual hash -/// -/// 5. Option Uniqueness (Success): -/// - test_option_value_uniqueness: assert_ne! between None/Some(0)/Some(1) hashes -/// - test_field_order_uniqueness: assert_ne! between different field orders -/// - test_truncated_option_uniqueness: assert_ne! between None/Some truncated hashes -/// -/// 6. Byte Representation (Success): -/// - test_truncate_byte_representation: assert_eq! truncated bytes match expected -/// - test_byte_representation_combinations: assert_eq! bytes match expected -/// -mod fixtures { - use super::*; - - pub fn create_nested_struct() -> MyNestedStruct { - MyNestedStruct { - a: i32::MIN, - b: u32::MAX, - c: "wao".to_string(), - } - } - - pub fn create_account(f: Option) -> MyAccount { - MyAccount { - a: true, - b: u64::MAX, - c: create_nested_struct(), - d: [u8::MAX; 32], - f, - } - } - - pub fn create_zero_nested() -> MyNestedStruct { - MyNestedStruct { - a: 0, - b: 0, - c: "".to_string(), - } - } -} - -mod basic_hashing { - use super::{fixtures::*, *}; - - #[test] - fn test_byte_representation() { - let nested_struct = create_nested_struct(); - let account = create_account(Some(42)); - - let manual_nested_bytes: Vec> = vec![ - nested_struct.a.to_be_bytes().to_vec(), - nested_struct.b.to_be_bytes().to_vec(), - light_compressed_account::hash_to_bn254_field_size_be( - nested_struct.c.try_to_vec().unwrap().as_slice(), - ) - .to_vec(), - ]; - - let nested_bytes: Vec<&[u8]> = manual_nested_bytes.iter().map(|v| v.as_slice()).collect(); - let manual_nested_hash = Poseidon::hashv(&nested_bytes).unwrap(); - - let nested_reference_hash = [ - 23, 168, 151, 171, 174, 194, 211, 73, 247, 130, 121, 180, 3, 103, 77, 84, 93, 124, 57, - 96, 100, 128, 168, 101, 212, 191, 249, 93, 115, 219, 37, 22, - ]; - let nested_hash_result = nested_struct.hash::().unwrap(); - - assert_eq!(nested_hash_result, manual_nested_hash); - assert_eq!(manual_nested_hash, nested_reference_hash); - assert_eq!(nested_hash_result, manual_nested_hash); - - let manual_account_bytes: Vec> = vec![ - vec![u8::from(account.a)], - account.b.to_be_bytes().to_vec(), - account.c.hash::().unwrap().to_vec(), - light_compressed_account::hash_to_bn254_field_size_be(&account.d).to_vec(), - { - let mut bytes = vec![0; 32]; - bytes[24..].copy_from_slice(&account.f.unwrap().to_be_bytes()); - bytes[23] = 1; // Suffix with 1 for Some - bytes - }, - ]; - - let account_bytes: Vec<&[u8]> = manual_account_bytes.iter().map(|v| v.as_slice()).collect(); - let manual_account_hash = Poseidon::hashv(&account_bytes).unwrap(); - - let account_hash_result = account.hash::().unwrap(); - - assert_eq!(account_hash_result, manual_account_hash); - } - - #[test] - fn test_zero_values() { - let nested = create_zero_nested(); - - let zero_account = MyAccount { - a: false, - b: 0, - c: nested, - d: [0; 32], - f: Some(0), - }; - - let manual_account_bytes = [ - [0u8; 32], - [0u8; 32], - zero_account.c.hash::().unwrap(), - light_compressed_account::hash_to_bn254_field_size_be(&zero_account.d), - { - let mut bytes = [0u8; 32]; - bytes[24..].copy_from_slice(&zero_account.f.unwrap().to_be_bytes()); - bytes[23] = 1; // Suffix with 1 for Some - bytes - }, - ]; - let account_bytes: Vec<&[u8]> = manual_account_bytes.iter().map(|v| v.as_slice()).collect(); - let manual_account_hash = Poseidon::hashv(&account_bytes).unwrap(); - let hash = zero_account.hash::().unwrap(); - assert_eq!(hash, manual_account_hash); - - let expected_hash = [ - 47, 62, 70, 12, 78, 227, 140, 201, 110, 213, 91, 205, 99, 218, 61, 163, 117, 26, 219, - 39, 235, 30, 172, 183, 161, 112, 98, 182, 145, 132, 9, 227, - ]; - assert_eq!(hash, expected_hash); - } -} - -mod attribute_behavior { - use super::{fixtures::*, *}; - - mod truncate { - use super::*; - - #[test] - fn test_array_truncation() { - #[derive(LightHasher)] - struct TruncatedStruct { - #[hash] - data: [u8; 32], - } - - let ones = TruncatedStruct { data: [1u8; 32] }; - let twos = TruncatedStruct { data: [2u8; 32] }; - let mixed = TruncatedStruct { - data: { - let mut data = [1u8; 32]; - data[0] = 2u8; - data - }, - }; - - let ones_hash = ones.hash::().unwrap(); - let twos_hash = twos.hash::().unwrap(); - let mixed_hash = mixed.hash::().unwrap(); - - assert_ne!(ones_hash, twos_hash); - assert_ne!(ones_hash, mixed_hash); - assert_ne!(twos_hash, mixed_hash); - } - - #[test] - fn test_truncation_longer_array() { - #[derive(LightHasher)] - struct LongTruncatedStruct { - #[hash] - data: String, - } - - let large_data = "a".repeat(64); - let truncated = LongTruncatedStruct { - data: large_data.clone(), - }; - - let mut modified_data = large_data.clone(); - modified_data.push('b'); - let truncated2 = LongTruncatedStruct { - data: modified_data, - }; - - let hash1 = truncated.hash::().unwrap(); - let hash2 = truncated2.hash::().unwrap(); - - assert_ne!(hash1, hash2); - } - - #[test] - fn test_multiple_truncates() { - #[derive(LightHasher)] - struct MultiTruncate { - #[hash] - data1: String, - #[hash] - data2: String, - } - - let test_struct = MultiTruncate { - data1: "a".repeat(64), - data2: "b".repeat(64), - }; - - let hash1 = test_struct.hash::().unwrap(); - - let test_struct2 = MultiTruncate { - data1: "a".repeat(65), - data2: "b".repeat(65), - }; - - let hash2 = test_struct2.hash::().unwrap(); - assert_ne!( - hash1, hash2, - "Different data should produce different hashes" - ); - } - - #[test] - fn test_nested_with_truncate() { - #[derive(LightHasher)] - struct NestedTruncate { - inner: MyNestedStruct, - #[hash] - data: String, - } - - let nested = create_nested_struct(); - let test_struct = NestedTruncate { - inner: nested, - data: "test".to_string(), - }; - - let manual_hash = Poseidon::hashv(&[ - &test_struct.inner.hash::().unwrap(), - &light_compressed_account::hash_to_bn254_field_size_be( - test_struct.data.try_to_vec().unwrap().as_slice(), - ), - ]) - .unwrap(); - - let hash = test_struct.hash::().unwrap(); - - // Updated reference hash for BE bytes - let reference_hash = [ - 23, 51, 46, 64, 164, 108, 180, 43, 103, 108, 36, 17, 191, 231, 210, 28, 178, 114, - 188, 37, 143, 15, 165, 109, 154, 241, 33, 210, 172, 108, 10, 33, - ]; - - assert_eq!(hash, manual_hash); - assert_eq!(hash, reference_hash); - } - } - - mod nested { - use super::*; - - #[test] - fn test_recursive_nesting() { - let nested_struct = create_nested_struct(); - - #[derive(LightHasher)] - struct TestNestedStruct { - one: MyNestedStruct, - - two: MyNestedStruct, - } - - let test_nested_struct = TestNestedStruct { - one: nested_struct, - two: create_nested_struct(), - }; - - let manual_hash = Poseidon::hashv(&[ - &test_nested_struct.one.hash::().unwrap(), - &test_nested_struct.two.hash::().unwrap(), - ]) - .unwrap(); - - assert_eq!(test_nested_struct.hash::().unwrap(), manual_hash); - } - - #[test] - fn test_nested_option() { - #[derive(LightHasher)] - struct NestedOption { - opt: Option, - } - - let with_some = NestedOption { - opt: Some(create_nested_struct()), - }; - let with_none = NestedOption { opt: None }; - - let some_bytes = - [ - Poseidon::hash( - &with_some.opt.as_ref().unwrap().hash::().unwrap()[..], - ) - .unwrap(), - ]; - let none_bytes = [[0u8; 32]]; - - assert_eq!(with_some.to_byte_array().unwrap(), some_bytes[0]); - println!("1"); - assert_eq!(with_none.to_byte_array().unwrap(), none_bytes[0]); - println!("1"); - - let some_hash = with_some.hash::().unwrap(); - let none_hash = with_none.hash::().unwrap(); - - assert_ne!(some_hash, none_hash); - } - - #[test] - fn test_nested_field_count() { - #[derive(LightHasher)] - struct InnerMaxFields { - f1: u64, - f2: u64, - f3: u64, - f4: u64, - f5: u64, - f6: u64, - f7: u64, - f8: u64, - f9: u64, - f10: u64, - f11: u64, - f12: u64, - } - - #[derive(LightHasher)] - struct OuterWithNested { - inner: InnerMaxFields, - other: u64, - } - - let inner = InnerMaxFields { - f1: 1, - f2: 2, - f3: 3, - f4: 4, - f5: 5, - f6: 6, - f7: 7, - f8: 8, - f9: 9, - f10: 10, - f11: 11, - f12: 12, - }; - - let outer = OuterWithNested { inner, other: 13 }; - - assert!(outer.hash::().is_ok()); - } - } -} - -#[test] -fn test_empty_struct() { - #[derive(LightHasher)] - struct EmptyStruct {} - - let empty = EmptyStruct {}; - let result = empty.hash::(); - - assert!(result.is_err(), "Empty struct should fail to hash"); -} - -#[test] -fn test_poseidon_width_limits() { - #[derive(LightHasher)] - struct MaxFields { - f1: u64, - f2: u64, - f3: u64, - f4: u64, - f5: u64, - f6: u64, - f7: u64, - f8: u64, - f9: u64, - f10: u64, - f11: u64, - f12: u64, - } - - let max_fields = MaxFields { - f1: 1, - f2: 2, - f3: 3, - f4: 4, - f5: 5, - f6: 6, - f7: 7, - f8: 8, - f9: 9, - f10: 10, - f11: 11, - f12: 12, - }; - - assert!(max_fields.hash::().is_ok()); - let expected_hash = Poseidon::hashv(&[ - 1u64.to_be_bytes().as_ref(), - 2u64.to_be_bytes().as_ref(), - 3u64.to_be_bytes().as_ref(), - 4u64.to_be_bytes().as_ref(), - 5u64.to_be_bytes().as_ref(), - 6u64.to_be_bytes().as_ref(), - 7u64.to_be_bytes().as_ref(), - 8u64.to_be_bytes().as_ref(), - 9u64.to_be_bytes().as_ref(), - 10u64.to_be_bytes().as_ref(), - 11u64.to_be_bytes().as_ref(), - 12u64.to_be_bytes().as_ref(), - ]) - .unwrap(); - assert_eq!(max_fields.hash::().unwrap(), expected_hash); - - // Doesn't compile because it has too many fields. - // #[derive(LightHasher)] - // struct TooManyFields { - // f1: u64, - // f2: u64, - // f3: u64, - // f4: u64, - // f5: u64, - // f6: u64, - // f7: u64, - // f8: u64, - // f9: u64, - // f10: u64, - // f11: u64, - // f12: u64, - // f13: u64, - // } -} - -/// Byte arrays over length 31 bytes need to be truncated or a custom ToByteArray impl. -#[test] -fn test_32_array_length() { - #[derive(LightHasher)] - struct OversizedArray { - #[hash] - data: [u8; 32], - } - - let test_struct = OversizedArray { data: [255u8; 32] }; - let expected_result = - Poseidon::hash(&hash_to_bn254_field_size_be(test_struct.data.as_slice())).unwrap(); - let result = test_struct.hash::().unwrap(); - assert_eq!(result, expected_result); -} - -/// doesn't compile without truncate -#[test] -fn test_option_array() { - #[derive(LightHasher)] - struct OptionArray { - #[hash] - data: Option<[u8; 32]>, - } - - let test_struct = OptionArray { - data: Some([0u8; 32]), - }; - - let result = test_struct.hash::().unwrap(); - assert_ne!(result, [0u8; 32]); - let expected_result = Poseidon::hash(&hash_to_bn254_field_size_be(&[0u8; 32][..])).unwrap(); - assert_eq!(result, expected_result); -} - -mod option_handling { - use super::{fixtures::*, *}; - - #[test] - fn test_option_hashing_with_reference_values() { - let account_none = create_account(None); - let none_hash = account_none.hash::().unwrap(); - - let account_some = create_account(Some(0)); - let some_hash = account_some.hash::().unwrap(); - - // Verify that None and Some(0) have different hashes - assert_ne!( - none_hash, some_hash, - "None and Some(0) should have different hashes" - ); - } - - #[test] - fn test_basic_option_variants() { - #[allow(dead_code)] - #[derive(LightHasher)] - struct BasicOptions { - small: Option, - large: Option, - #[hash] - empty_str: Option, - } - - let test_struct = BasicOptions { - small: Some(42), - large: Some(u64::MAX), - empty_str: Some("".to_string()), - }; - - let none_struct = BasicOptions { - small: None, - large: None, - empty_str: None, - }; - - let manual_bytes = [ - { - let mut bytes = [0u8; 32]; - bytes[28..].copy_from_slice(&42u32.to_be_bytes()); - bytes[27] = 1; // Prefix with 1 for Some - bytes - }, - { - let mut bytes = [0u8; 32]; - bytes[24..].copy_from_slice(&u64::MAX.to_be_bytes()); - bytes[23] = 1; // Prefix with 1 for Some - bytes - }, - light_compressed_account::hash_to_bn254_field_size_be( - "".try_to_vec().unwrap().as_slice(), - ), - ]; - - assert_eq!(test_struct.hash::(), test_struct.to_byte_array()); - let expected_hash = Poseidon::hashv( - &manual_bytes - .iter() - .map(|x| x.as_slice()) - .collect::>(), - ) - .unwrap(); - assert_eq!(test_struct.hash::().unwrap(), expected_hash); - let test_hash = test_struct.hash::(); - assert!(test_hash.is_ok()); - let none_hash = none_struct.hash::().unwrap(); - - // Verify that None and Some produce different hashes - assert_ne!( - test_hash.unwrap(), - none_hash, - "None and Some should have different hashes" - ); - } - - #[test] - fn test_truncated_option_variants() { - #[derive(LightHasher)] - struct TruncatedOptions { - #[hash] - empty_str: Option, - #[hash] - short_str: Option, - #[hash] - long_str: Option, - #[hash] - large_array: Option<[u8; 64]>, - } - - let test_struct = TruncatedOptions { - empty_str: Some("".to_string()), - short_str: Some("test".to_string()), - long_str: Some("a".repeat(100)), - large_array: Some([42u8; 64]), - }; - - let none_struct = TruncatedOptions { - empty_str: None, - short_str: None, - long_str: None, - large_array: None, - }; - - let manual_some_bytes = [ - light_compressed_account::hash_to_bn254_field_size_be( - "".try_to_vec().unwrap().as_slice(), - ), - light_compressed_account::hash_to_bn254_field_size_be( - "test".try_to_vec().unwrap().as_slice(), - ), - light_compressed_account::hash_to_bn254_field_size_be( - "a".repeat(100).try_to_vec().unwrap().as_slice(), - ), - light_compressed_account::hash_to_bn254_field_size_be( - &test_struct.large_array.unwrap(), - ), - ]; - - let test_hash = test_struct.hash::().unwrap(); - let none_hash = none_struct.hash::().unwrap(); - let expeceted_some_hash = Poseidon::hashv( - &manual_some_bytes - .iter() - .map(|x| x.as_slice()) - .collect::>(), - ) - .unwrap(); - let expected_none_hash = - Poseidon::hashv(&[&[0; 32], &[0; 32], &[0; 32], &[0; 32]]).unwrap(); - assert_eq!(test_hash, expeceted_some_hash); - assert_eq!(none_hash, expected_none_hash); - // Updated reference hash for BE bytes - assert_eq!( - test_hash, - [ - 26, 206, 86, 217, 69, 163, 110, 158, 101, 48, 167, 203, 138, 17, 126, 43, 203, 82, - 148, 165, 167, 144, 44, 120, 82, 49, 202, 62, 109, 206, 237, 190 - ] - ); - // Updated reference hash for BE bytes - assert_eq!( - none_hash, - [ - 5, 50, 253, 67, 110, 25, 199, 14, 81, 32, 150, 148, 217, 194, 21, 37, 9, 55, 146, - 27, 139, 121, 6, 4, 136, 193, 32, 109, 183, 62, 153, 70 - ] - ); - } - - #[test] - fn test_nested_option_variants() { - #[derive(LightHasher)] - struct NestedOptions { - empty_struct: Option, - full_struct: Option, - } - - let empty_nested = create_zero_nested(); - let full_nested = create_nested_struct(); - - let test_struct = NestedOptions { - empty_struct: Some(empty_nested), - full_struct: Some(full_nested), - }; - - let none_struct = NestedOptions { - empty_struct: None, - full_struct: None, - }; - - let manual_bytes = [ - Poseidon::hash( - &test_struct - .empty_struct - .as_ref() - .unwrap() - .hash::() - .unwrap(), - ) - .unwrap(), - Poseidon::hash( - &test_struct - .full_struct - .as_ref() - .unwrap() - .hash::() - .unwrap(), - ) - .unwrap(), - ]; - - let expected_hash = - Poseidon::hashv(&manual_bytes.iter().map(|x| x.as_ref()).collect::>()).unwrap(); - assert_eq!(test_struct.hash::().unwrap(), expected_hash); - // Updated reference hash for BE bytes - assert_eq!( - test_struct.hash::().unwrap(), - [ - 38, 207, 53, 149, 51, 139, 156, 60, 155, 207, 232, 222, 177, 238, 31, 130, 136, - 224, 210, 74, 144, 46, 141, 195, 34, 135, 83, 198, 233, 159, 168, 143 - ] - ); - // Updated reference hash for BE bytes - assert_eq!( - none_struct.hash::().unwrap(), - [ - 32, 152, 245, 251, 158, 35, 158, 171, 60, 234, 195, 242, 123, 129, 228, 129, 220, - 49, 36, 213, 95, 254, 213, 35, 168, 57, 238, 132, 70, 182, 72, 100 - ] - ); - } - - #[test] - fn test_mixed_option_combinations() { - #[derive(LightHasher)] - struct MixedOptions { - basic: Option, - #[hash] - truncated_small: Option, - #[hash] - truncated_large: Option<[u8; 64]>, - - nested_empty: Option, - - nested_full: Option, - } - - let test_struct = MixedOptions { - basic: Some(42), - truncated_small: Some("test".to_string()), - truncated_large: Some([42u8; 64]), - nested_empty: Some(MyNestedStruct { - a: 0, - b: 0, - c: "".to_string(), - }), - nested_full: Some(create_nested_struct()), - }; - - let partial_struct = MixedOptions { - basic: Some(42), - truncated_small: None, - truncated_large: Some([42u8; 64]), - nested_empty: None, - nested_full: Some(create_nested_struct()), - }; - - let none_struct = MixedOptions { - basic: None, - truncated_small: None, - truncated_large: None, - nested_empty: None, - nested_full: None, - }; - - let manual_bytes = [ - { - let mut bytes = [0u8; 32]; - bytes[28..].copy_from_slice(&42u32.to_be_bytes()); - bytes[27] = 1; - bytes - }, - light_compressed_account::hash_to_bn254_field_size_be( - "test".try_to_vec().unwrap().as_slice(), - ), - light_compressed_account::hash_to_bn254_field_size_be(&[42u8; 64][..]), - Poseidon::hash( - &test_struct - .nested_empty - .as_ref() - .unwrap() - .hash::() - .unwrap(), - ) - .unwrap(), - Poseidon::hash( - &test_struct - .nested_full - .as_ref() - .unwrap() - .hash::() - .unwrap(), - ) - .unwrap(), - ]; - - let expected_hash = - Poseidon::hashv(&manual_bytes.iter().map(|x| x.as_ref()).collect::>()).unwrap(); - assert_eq!(test_struct.hash::().unwrap(), expected_hash); - assert_eq!( - test_struct.hash::().unwrap(), - [ - 11, 157, 253, 114, 25, 23, 79, 182, 68, 25, 62, 21, 54, 17, 133, 132, 46, 211, 241, - 153, 207, 76, 61, 164, 177, 148, 208, 53, 50, 179, 26, 213 - ] - ); - // Updated reference hash for BE bytes - assert_eq!( - partial_struct.hash::().unwrap(), - [ - 37, 131, 136, 26, 175, 106, 143, 121, 184, 59, 76, 126, 15, 134, 111, 55, 194, 38, - 166, 191, 109, 79, 125, 48, 141, 129, 166, 234, 210, 243, 93, 144 - ] - ); - // Updated reference hash for BE bytes - assert_eq!( - none_struct.hash::().unwrap(), - [ - 32, 102, 190, 65, 190, 190, 108, 175, 126, 7, 147, 96, 171, 225, 79, 191, 145, 24, - 198, 46, 171, 196, 46, 47, 231, 94, 52, 43, 22, 10, 149, 188 - ] - ); - } - - #[test] - fn test_nested_struct_with_options() { - #[derive(LightHasher)] - struct InnerWithOptions { - basic: Option, - #[hash] - truncated: Option, - } - - #[derive(LightHasher)] - struct OuterStruct { - inner: InnerWithOptions, - basic: Option, - } - - let test_struct = OuterStruct { - inner: InnerWithOptions { - basic: Some(42), - truncated: Some("test".to_string()), - }, - basic: Some(u64::MAX), - }; - - let none_struct = OuterStruct { - inner: InnerWithOptions { - basic: None, - truncated: None, - }, - basic: None, - }; - - let manual_bytes = [test_struct.inner.hash::().unwrap(), { - let mut bytes = [0u8; 32]; - bytes[24..].copy_from_slice(&u64::MAX.to_be_bytes()); - bytes[23] = 1; - bytes - }]; - - let expected_hash = Poseidon::hashv( - manual_bytes - .iter() - .map(|x| x.as_slice()) - .collect::>() - .as_slice(), - ) - .unwrap(); - assert_eq!(test_struct.hash::().unwrap(), expected_hash); - assert_eq!( - test_struct.hash::().unwrap(), - [ - 12, 235, 222, 198, 73, 228, 229, 31, 235, 53, 206, 115, 238, 91, 183, 135, 185, - 105, 2, 255, 171, 222, 207, 6, 189, 151, 58, 172, 28, 183, 57, 92 - ] - ); - // Updated reference hash for BE bytes - assert_eq!( - none_struct.hash::().unwrap(), - [ - 23, 83, 82, 87, 94, 164, 86, 13, 119, 230, 225, 21, 182, 59, 41, 174, 42, 2, 191, - 189, 157, 234, 195, 122, 103, 142, 82, 137, 231, 49, 77, 106 - ] - ); - } -} - -mod option_uniqueness { - use super::*; - // TODO: split into multi tests to ensure ne is attributable - #[test] - fn test_option_value_uniqueness() { - #[derive(LightHasher)] - struct OptionTest { - a: Option, - b: Option, - #[hash] - c: Option, - - d: Option, - } - - // Test None vs Some(0) produce different hashes - let none_struct = OptionTest { - a: None, - b: None, - c: None, - d: None, - }; - - let zero_struct = OptionTest { - a: Some(0), - b: Some(0), - c: Some("".to_string()), - d: Some(MyNestedStruct { - a: 0, - b: 0, - c: "".to_string(), - }), - }; - - assert_ne!( - none_struct.hash::().unwrap(), - zero_struct.hash::().unwrap(), - "None should hash differently than Some(0)" - ); - - // Test different Some values produce different hashes - let one_struct = OptionTest { - a: Some(1), - b: Some(1), - c: Some("a".to_string()), - d: Some(MyNestedStruct { - a: 1, - b: 1, - c: "a".to_string(), - }), - }; - - assert_ne!( - zero_struct.hash::().unwrap(), - one_struct.hash::().unwrap(), - "Different Some values should hash differently" - ); - - // Test partial Some/None combinations - let partial_struct = OptionTest { - a: Some(1), - b: None, - c: Some("a".to_string()), - d: None, - }; - - assert_ne!( - none_struct.hash::().unwrap(), - partial_struct.hash::().unwrap(), - "Partial Some/None should hash differently than all None" - ); - assert_ne!( - one_struct.hash::().unwrap(), - partial_struct.hash::().unwrap(), - "Partial Some/None should hash differently than all Some" - ); - } - - #[test] - fn test_field_order_uniqueness() { - // Test that field order matters for options - #[derive(LightHasher)] - struct OrderTestA { - first: Option, - second: Option, - } - - #[derive(LightHasher)] - struct OrderTestB { - first: Option, - second: Option, - } - - let test_a = OrderTestA { - first: Some(1), - second: Some(2), - }; - - let test_b = OrderTestB { - first: Some(2), - second: Some(1), - }; - - assert_ne!( - test_a.hash::().unwrap(), - test_b.hash::().unwrap(), - "Different field order should produce different hashes" - ); - - // Test nested option field order - #[derive(LightHasher)] - struct NestedOrderTestA { - first: Option, - second: Option, - } - - #[derive(LightHasher)] - struct NestedOrderTestB { - first: Option, - - second: Option, - } - - let nested_a = NestedOrderTestA { - first: Some(MyNestedStruct { - a: 1, - b: 2, - c: "test".to_string(), - }), - second: Some(42), - }; - - let nested_b = NestedOrderTestB { - first: Some(42), - second: Some(MyNestedStruct { - a: 1, - b: 2, - c: "test".to_string(), - }), - }; - - assert_ne!( - nested_a.hash::().unwrap(), - nested_b.hash::().unwrap(), - "Different nested field order should produce different hashes" - ); - } - - #[test] - fn test_truncated_option_uniqueness() { - #[derive(LightHasher)] - struct TruncateTest { - #[hash] - a: Option, - #[hash] - b: Option<[u8; 64]>, - } - - // Test truncated None vs empty - let none_struct = TruncateTest { a: None, b: None }; - - let empty_struct = TruncateTest { - a: Some("".to_string()), - b: Some([0u8; 64]), - }; - - assert_ne!( - none_struct.hash::().unwrap(), - empty_struct.hash::().unwrap(), - "Truncated None should hash differently than empty values" - ); - - // Test truncated different values - let value_struct = TruncateTest { - a: Some("test".to_string()), - b: Some([1u8; 64]), - }; - - assert_ne!( - empty_struct.hash::().unwrap(), - value_struct.hash::().unwrap(), - "Different truncated values should hash differently" - ); - - // Test truncated long values - let long_struct = TruncateTest { - a: Some("a".repeat(100)), - b: Some([2u8; 64]), - }; - - assert_ne!( - value_struct.hash::().unwrap(), - long_struct.hash::().unwrap(), - "Different length truncated values should hash differently" - ); - } -} - -#[test] -fn test_solana_program_pubkey() { - // Pubkey field - { - #[derive(LightHasher)] - pub struct PubkeyStruct { - #[hash] - pub pubkey: Pubkey, - } - let pubkey_struct = PubkeyStruct { - pubkey: Pubkey::new_unique(), - }; - - let manual_hash = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be(pubkey_struct.pubkey.as_ref()) - .as_slice(), - ) - .unwrap(); - let manual_hash_borsh = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be( - pubkey_struct.pubkey.try_to_vec().unwrap().as_slice(), - ) - .as_slice(), - ) - .unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - assert_eq!(manual_hash_borsh, hash); - } - // Option - { - #[derive(LightHasher)] - pub struct PubkeyStruct { - #[hash] - pub pubkey: Option, - } - // Some - { - let pubkey_struct = PubkeyStruct { - pubkey: Some(Pubkey::new_unique()), - }; - let manual_bytes = pubkey_struct.pubkey.unwrap().try_to_vec().unwrap(); - - let manual_hash = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } - // None - { - let pubkey_struct = PubkeyStruct { pubkey: None }; - let manual_hash = Poseidon::hash([0u8; 32].as_slice()).unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash([0u8; 32].as_slice()).unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } - } - // Vec - { - #[derive(LightHasher)] - pub struct PubkeyStruct { - #[hash] - pub pubkey: Vec, - } - let pubkey_vec = (0..3).map(|_| Pubkey::new_unique()).collect::>(); - let pubkey_struct = PubkeyStruct { pubkey: pubkey_vec }; - let manual_bytes = pubkey_struct.pubkey.try_to_vec().unwrap(); - - let manual_hash = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } - // Vec> - { - #[derive(LightHasher)] - pub struct PubkeyStruct { - #[hash] - pub pubkey: Vec>, - } - // Some - { - let pubkey_vec = (0..3) - .map(|_| Some(Pubkey::new_unique())) - .collect::>(); - let pubkey_struct = PubkeyStruct { pubkey: pubkey_vec }; - let manual_bytes = pubkey_struct.pubkey.try_to_vec().unwrap(); - - let manual_hash = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } - // None - { - let pubkey_vec = (0..3).map(|_| None).collect::>(); - let pubkey_struct = PubkeyStruct { pubkey: pubkey_vec }; - let manual_bytes = pubkey_struct.pubkey.try_to_vec().unwrap(); - let manual_hash = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } - } -} - -#[test] -fn test_light_hasher_sha_macro() { - use light_sdk_macros::LightHasherSha; - - // Test struct with many fields that would exceed Poseidon's limit - #[derive(LightHasherSha, BorshSerialize, BorshDeserialize, Clone)] - struct LargeShaStruct { - pub field1: u64, - pub field2: u64, - pub field3: u64, - pub field4: u64, - pub field5: u64, - pub field6: u64, - pub field7: u64, - pub field8: u64, - pub field9: u64, - pub field10: u64, - pub field11: u64, - pub field12: u64, - pub field13: u64, - pub field14: u64, - pub field15: u64, - pub owner: Pubkey, - pub authority: Pubkey, - } - - let test_struct = LargeShaStruct { - field1: 1, - field2: 2, - field3: 3, - field4: 4, - field5: 5, - field6: 6, - field7: 7, - field8: 8, - field9: 9, - field10: 10, - field11: 11, - field12: 12, - field13: 13, - field14: 14, - field15: 15, - owner: Pubkey::new_unique(), - authority: Pubkey::new_unique(), - }; - - // Verify the hash matches manual SHA256 hashing - let bytes = test_struct.try_to_vec().unwrap(); - let mut ref_hash = Sha256::hash(bytes.as_slice()).unwrap(); - - // Apply truncation for non-Poseidon hashers (ID != 0) - if Sha256::ID != 0 { - ref_hash[0] = 0; - } - - // Test with SHA256 hasher - let hash_result = test_struct.hash::().unwrap(); - assert_eq!( - hash_result, ref_hash, - "SHA256 hash should match manual hash" - ); - - // Test ToByteArray implementation - let byte_array_result = test_struct.to_byte_array().unwrap(); - assert_eq!( - byte_array_result, ref_hash, - "ToByteArray should match SHA256 hash" - ); - - // Test another struct with different values - let test_struct2 = LargeShaStruct { - field1: 100, - field2: 200, - field3: 300, - field4: 400, - field5: 500, - field6: 600, - field7: 700, - field8: 800, - field9: 900, - field10: 1000, - field11: 1100, - field12: 1200, - field13: 1300, - field14: 1400, - field15: 1500, - owner: Pubkey::new_unique(), - authority: Pubkey::new_unique(), - }; - - let bytes2 = test_struct2.try_to_vec().unwrap(); - let mut ref_hash2 = Sha256::hash(bytes2.as_slice()).unwrap(); - - if Sha256::ID != 0 { - ref_hash2[0] = 0; - } - - let hash_result2 = test_struct2.hash::().unwrap(); - assert_eq!( - hash_result2, ref_hash2, - "Second SHA256 hash should match manual hash" - ); - - // Ensure different structs produce different hashes - assert_ne!( - hash_result, hash_result2, - "Different structs should produce different hashes" - ); -} - -// Option -#[test] -fn test_borsh() { - #[derive(BorshDeserialize, BorshSerialize)] - pub struct BorshStruct { - data: [u8; 34], - } - impl Default for BorshStruct { - fn default() -> Self { - Self { data: [1u8; 34] } - } - } - // Option Borsh - { - #[derive(LightHasher)] - pub struct PubkeyStruct { - #[hash] - pub pubkey: Option, - } - // Some - { - let pubkey_struct = PubkeyStruct { - pubkey: Some(BorshStruct::default()), - }; - let manual_bytes = pubkey_struct.pubkey.as_ref().unwrap().try_to_vec().unwrap(); - - let manual_hash = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } - // None - { - let pubkey_struct = PubkeyStruct { pubkey: None }; - let manual_hash = Poseidon::hash([0u8; 32].as_slice()).unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash([0u8; 32].as_slice()).unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } - } - // Borsh - { - #[derive(LightHasher)] - pub struct PubkeyStruct { - #[hash] - pub pubkey: BorshStruct, - } - - let pubkey_struct = PubkeyStruct { - pubkey: BorshStruct::default(), - }; - let manual_bytes = pubkey_struct.pubkey.try_to_vec().unwrap(); - - let manual_hash = Poseidon::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - - // Sha256 - let mut manual_hash = Sha256::hash( - light_compressed_account::hash_to_bn254_field_size_be(manual_bytes.as_slice()) - .as_slice(), - ) - .unwrap(); - // Apply truncation for non-Poseidon hashers - if Sha256::ID != 0 { - manual_hash[0] = 0; - } - let hash = pubkey_struct.hash::().unwrap(); - assert_eq!(manual_hash, hash); - } -} diff --git a/sdk-libs/macros/tests/pda.rs b/sdk-libs/macros/tests/pda.rs deleted file mode 100644 index 50fb33782e..0000000000 --- a/sdk-libs/macros/tests/pda.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::str::FromStr; - -use light_macros::derive_light_cpi_signer; -use light_sdk_types::CpiSigner; -use solana_pubkey::Pubkey; - -#[test] -fn test_compute_pda_basic() { - // Test with a known program ID using fixed "cpi_authority" seed - const RESULT: CpiSigner = - derive_light_cpi_signer!("SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7"); - - // Verify the result has valid fields - assert_eq!(RESULT.program_id.len(), 32); - assert_eq!(RESULT.cpi_signer.len(), 32); - - // Verify this matches runtime computation - let runtime_result = Pubkey::find_program_address( - &[b"cpi_authority"], - &Pubkey::from_str("SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7").unwrap(), - ); - - assert_eq!(RESULT.cpi_signer, runtime_result.0.to_bytes()); - assert_eq!(RESULT.bump, runtime_result.1); -} - -#[test] -fn test_cpi_signer() { - // Test that the macro can be used in const contexts - const PDA_RESULT: CpiSigner = - derive_light_cpi_signer!("SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7"); - - // Extract individual components in const context - const PROGRAM_ID: [u8; 32] = PDA_RESULT.program_id; - const CPI_SIGNER: [u8; 32] = PDA_RESULT.cpi_signer; - const BUMP: u8 = PDA_RESULT.bump; - - // Verify they're valid - assert_eq!( - PROGRAM_ID, - light_macros::pubkey_array!("SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7") - ); - assert_eq!( - CPI_SIGNER, - [ - 251, 179, 40, 117, 16, 92, 174, 133, 181, 180, 68, 118, 7, 237, 191, 225, 69, 39, 191, - 180, 35, 145, 28, 164, 4, 35, 191, 209, 82, 122, 38, 117 - ] - ); - assert_eq!(BUMP, 255); -} - -#[test] -fn test_cpi_signer_2() { - // Test that the macro can be used in const contexts - const PDA_RESULT: CpiSigner = - derive_light_cpi_signer!("compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq"); - - // Extract individual components in const context - const PROGRAM_ID: [u8; 32] = PDA_RESULT.program_id; - const CPI_SIGNER: [u8; 32] = PDA_RESULT.cpi_signer; - const BUMP: u8 = PDA_RESULT.bump; - - // Verify they're valid - assert_eq!( - PROGRAM_ID, - light_macros::pubkey_array!("compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq") - ); - assert_eq!( - CPI_SIGNER, - [ - 20, 12, 243, 109, 120, 11, 194, 48, 169, 64, 170, 103, 246, 66, 224, 151, 74, 116, 57, - 84, 0, 180, 16, 126, 175, 149, 24, 207, 85, 137, 3, 207 - ] - ); - assert_eq!(BUMP, 255); -} diff --git a/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml index 1e92999796..4941c8cfb6 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml +++ b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml @@ -30,7 +30,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 = "d8a2b3d9", features = ["memo", "metadata", "idl-build"] } +anchor-spl = { version = "=0.31.1", git = "https://github.com/lightprotocol/anchor", rev = "4e8ff59a7de5b2a8ba37f88cf7b0431999f1b1f3", 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/deposit.rs b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/deposit.rs new file mode 100644 index 0000000000..6d5f733314 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/deposit.rs @@ -0,0 +1,87 @@ +//! Deposit instruction with MintToCpi. + +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_token_sdk::token::MintToCpi; + +use super::states::*; + +#[derive(Accounts)] +pub struct Deposit<'info> { + #[account(mut)] + pub owner: Signer<'info>, + + #[account( + mut, + seeds = [AUTH_SEED.as_bytes()], + bump, + )] + pub authority: UncheckedAccount<'info>, + + #[account(mut)] + pub pool_state: Box>, + + #[account(mut)] + pub owner_lp_token: UncheckedAccount<'info>, + + #[account( + mut, + token::mint = vault_0_mint, + token::authority = owner, + )] + pub token_0_account: Box>, + + #[account( + mut, + token::mint = vault_1_mint, + token::authority = owner, + )] + pub token_1_account: Box>, + + #[account( + mut, + constraint = token_0_vault.key() == pool_state.token_0_vault, + )] + pub token_0_vault: Box>, + + #[account( + mut, + constraint = token_1_vault.key() == pool_state.token_1_vault, + )] + pub token_1_vault: Box>, + + #[account(address = pool_state.token_0_mint)] + pub vault_0_mint: Box>, + + #[account(address = pool_state.token_1_mint)] + pub vault_1_mint: Box>, + + #[account( + mut, + constraint = lp_mint.key() == pool_state.lp_mint, + )] + pub lp_mint: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub token_program_2022: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +/// Deposit instruction handler with MintToCpi. +pub fn process_deposit(ctx: Context, lp_token_amount: u64) -> Result<()> { + let pool_state = &ctx.accounts.pool_state; + let auth_bump = pool_state.auth_bump; + + // Mint LP tokens to owner using MintToCpi + MintToCpi { + mint: ctx.accounts.lp_mint.to_account_info(), + destination: ctx.accounts.owner_lp_token.to_account_info(), + amount: lp_token_amount, + authority: ctx.accounts.authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + } + .invoke_signed(&[&[AUTH_SEED.as_bytes(), &[auth_bump]]])?; + + Ok(()) +} 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 new file mode 100644 index 0000000000..d9712eff94 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs @@ -0,0 +1,258 @@ +//! Initialize instruction with all rentfree markers. +//! +//! Tests: +//! - 2x #[rentfree] (pool_state, observation_state) +//! - 2x #[rentfree_token(authority = [...])] (token_0_vault, token_1_vault) +//! - 1x #[light_mint(...)] (lp_mint) +//! - CreateTokenAccountCpi.rent_free() +//! - CreateTokenAtaCpi.rent_free() +//! - MintToCpi + +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::RentFree; +use light_token_sdk::token::{ + CreateTokenAccountCpi, CreateTokenAtaCpi, MintToCpi, COMPRESSIBLE_CONFIG_V1, + RENT_SPONSOR as CTOKEN_RENT_SPONSOR, +}; + +use super::states::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeParams { + pub init_amount_0: u64, + pub init_amount_1: u64, + pub open_time: u64, + pub create_accounts_proof: CreateAccountsProof, + pub lp_mint_signer_bump: u8, + pub creator_lp_token_bump: u8, + pub authority_bump: u8, +} + +#[derive(Accounts, RentFree)] +#[instruction(params: InitializeParams)] +pub struct InitializePool<'info> { + #[account(mut)] + pub creator: Signer<'info>, + + /// CHECK: AMM config account + pub amm_config: AccountInfo<'info>, + + #[account( + mut, + seeds = [AUTH_SEED.as_bytes()], + bump, + )] + pub authority: UncheckedAccount<'info>, + + #[account( + init, + seeds = [ + POOL_SEED.as_bytes(), + amm_config.key().as_ref(), + token_0_mint.key().as_ref(), + token_1_mint.key().as_ref(), + ], + bump, + payer = creator, + space = 8 + PoolState::INIT_SPACE + )] + #[rentfree] + pub pool_state: Box>, + + #[account( + constraint = token_0_mint.key() < token_1_mint.key(), + mint::token_program = token_0_program, + )] + pub token_0_mint: Box>, + + #[account(mint::token_program = token_1_program)] + pub token_1_mint: Box>, + + #[account( + seeds = [POOL_LP_MINT_SIGNER_SEED, pool_state.key().as_ref()], + bump, + )] + pub lp_mint_signer: UncheckedAccount<'info>, + + #[account(mut)] + #[light_mint( + mint_signer = lp_mint_signer, + 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]] + )] + pub lp_mint: UncheckedAccount<'info>, + + #[account( + mut, + token::mint = token_0_mint, + token::authority = creator, + )] + pub creator_token_0: Box>, + + #[account( + mut, + token::mint = token_1_mint, + token::authority = creator, + )] + pub creator_token_1: Box>, + + #[account(mut)] + pub creator_lp_token: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [ + POOL_VAULT_SEED.as_bytes(), + pool_state.key().as_ref(), + token_0_mint.key().as_ref() + ], + bump, + )] + #[rentfree_token(authority = [AUTH_SEED.as_bytes()])] + pub token_0_vault: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [ + POOL_VAULT_SEED.as_bytes(), + pool_state.key().as_ref(), + token_1_mint.key().as_ref() + ], + bump, + )] + #[rentfree_token(authority = [AUTH_SEED.as_bytes()])] + pub token_1_vault: UncheckedAccount<'info>, + + #[account( + init, + seeds = [OBSERVATION_SEED.as_bytes(), pool_state.key().as_ref()], + bump, + payer = creator, + space = 8 + ObservationState::INIT_SPACE + )] + #[rentfree] + pub observation_state: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub token_0_program: Interface<'info, TokenInterface>, + pub token_1_program: Interface<'info, TokenInterface>, + /// CHECK: Associated token program (SPL ATA or Light Token). + pub associated_token_program: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, + + pub compression_config: AccountInfo<'info>, + + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub ctoken_compressible_config: AccountInfo<'info>, + + #[account(mut, address = CTOKEN_RENT_SPONSOR)] + pub ctoken_rent_sponsor: AccountInfo<'info>, + + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority. + pub ctoken_cpi_authority: AccountInfo<'info>, +} + +/// Initialize instruction handler (noop for compilation test). +pub fn process_initialize_pool<'info>( + ctx: Context<'_, '_, '_, 'info, InitializePool<'info>>, + params: InitializeParams, +) -> Result<()> { + let pool_state_key = ctx.accounts.pool_state.key(); + + // Create token_0 vault using CreateTokenAccountCpi.rent_free() + CreateTokenAccountCpi { + payer: ctx.accounts.creator.to_account_info(), + account: ctx.accounts.token_0_vault.to_account_info(), + mint: ctx.accounts.token_0_mint.to_account_info(), + owner: ctx.accounts.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(&[ + POOL_VAULT_SEED.as_bytes(), + pool_state_key.as_ref(), + ctx.accounts.token_0_mint.key().as_ref(), + &[ctx.bumps.token_0_vault], + ])?; + + // Create token_1 vault using CreateTokenAccountCpi.rent_free() + CreateTokenAccountCpi { + payer: ctx.accounts.creator.to_account_info(), + account: ctx.accounts.token_1_vault.to_account_info(), + mint: ctx.accounts.token_1_mint.to_account_info(), + owner: ctx.accounts.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(&[ + POOL_VAULT_SEED.as_bytes(), + pool_state_key.as_ref(), + ctx.accounts.token_1_mint.key().as_ref(), + &[ctx.bumps.token_1_vault], + ])?; + + // Create creator LP token ATA using CreateTokenAtaCpi.rent_free() + CreateTokenAtaCpi { + payer: ctx.accounts.creator.to_account_info(), + owner: ctx.accounts.creator.to_account_info(), + mint: ctx.accounts.lp_mint.to_account_info(), + ata: ctx.accounts.creator_lp_token.to_account_info(), + bump: params.creator_lp_token_bump, + } + .idempotent() + .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(), + ) + .invoke()?; + + // Mint LP tokens using MintToCpi + let lp_amount = 1000u64; // Placeholder amount + MintToCpi { + mint: ctx.accounts.lp_mint.to_account_info(), + destination: ctx.accounts.creator_lp_token.to_account_info(), + amount: lp_amount, + authority: ctx.accounts.authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + } + .invoke_signed(&[&[AUTH_SEED.as_bytes(), &[ctx.bumps.authority]]])?; + + // Populate pool state + let pool_state = &mut ctx.accounts.pool_state; + pool_state.amm_config = ctx.accounts.amm_config.key(); + pool_state.pool_creator = ctx.accounts.creator.key(); + pool_state.token_0_vault = ctx.accounts.token_0_vault.key(); + pool_state.token_1_vault = ctx.accounts.token_1_vault.key(); + pool_state.lp_mint = ctx.accounts.lp_mint.key(); + pool_state.token_0_mint = ctx.accounts.token_0_mint.key(); + pool_state.token_1_mint = ctx.accounts.token_1_mint.key(); + pool_state.token_0_program = ctx.accounts.token_0_program.key(); + pool_state.token_1_program = ctx.accounts.token_1_program.key(); + pool_state.observation_key = ctx.accounts.observation_state.key(); + pool_state.auth_bump = ctx.bumps.authority; + pool_state.status = 1; // Active + pool_state.lp_mint_decimals = 9; + pool_state.mint_0_decimals = 9; + pool_state.mint_1_decimals = 9; + pool_state.lp_supply = lp_amount; + pool_state.open_time = params.open_time; + + Ok(()) +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/mod.rs new file mode 100644 index 0000000000..a8a3ff5b5d --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/mod.rs @@ -0,0 +1,19 @@ +//! AMM test cases based on cp-swap-reference patterns. +//! +//! Tests: +//! - Multiple #[rentfree] fields +//! - #[rentfree_token] with authority seeds +//! - #[light_mint] for LP token creation +//! - CreateTokenAccountCpi.rent_free() +//! - CreateTokenAtaCpi.rent_free() +//! - MintToCpi / BurnCpi + +mod deposit; +mod initialize; +mod states; +mod withdraw; + +pub use deposit::*; +pub use initialize::*; +pub use states::*; +pub use withdraw::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/states.rs b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/states.rs new file mode 100644 index 0000000000..bcae3c176f --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/states.rs @@ -0,0 +1,61 @@ +//! AMM state structs adapted from cp-swap-reference. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +pub const POOL_SEED: &str = "pool"; +pub const POOL_VAULT_SEED: &str = "pool_vault"; +pub const OBSERVATION_SEED: &str = "observation"; +pub const POOL_LP_MINT_SIGNER_SEED: &[u8] = b"pool_lp_mint"; +pub const AUTH_SEED: &str = "vault_and_lp_mint_auth_seed"; + +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +#[repr(C)] +pub struct PoolState { + pub compression_info: Option, + pub amm_config: Pubkey, + pub pool_creator: Pubkey, + pub token_0_vault: Pubkey, + pub token_1_vault: Pubkey, + pub lp_mint: Pubkey, + pub token_0_mint: Pubkey, + pub token_1_mint: Pubkey, + pub token_0_program: Pubkey, + pub token_1_program: Pubkey, + pub observation_key: Pubkey, + pub auth_bump: u8, + pub status: u8, + pub lp_mint_decimals: u8, + pub mint_0_decimals: u8, + pub mint_1_decimals: u8, + pub lp_supply: u64, + pub protocol_fees_token_0: u64, + pub protocol_fees_token_1: u64, + pub fund_fees_token_0: u64, + pub fund_fees_token_1: u64, + pub open_time: u64, + pub recent_epoch: u64, + pub padding: [u64; 1], +} + +pub const OBSERVATION_NUM: usize = 2; + +#[derive(Default, Clone, Copy, AnchorSerialize, AnchorDeserialize, InitSpace, Debug)] +pub struct Observation { + pub block_timestamp: u64, + pub cumulative_token_0_price_x32: u128, + pub cumulative_token_1_price_x32: u128, +} + +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct ObservationState { + pub compression_info: Option, + pub initialized: bool, + pub observation_index: u16, + pub pool_id: Pubkey, + pub observations: [Observation; OBSERVATION_NUM], + pub padding: [u64; 4], +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/withdraw.rs b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/withdraw.rs new file mode 100644 index 0000000000..0ca248350f --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/withdraw.rs @@ -0,0 +1,83 @@ +//! Withdraw instruction with BurnCpi. + +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_token_sdk::token::BurnCpi; + +use super::states::*; + +#[derive(Accounts)] +pub struct Withdraw<'info> { + #[account(mut)] + pub owner: Signer<'info>, + + #[account( + mut, + seeds = [AUTH_SEED.as_bytes()], + bump, + )] + pub authority: UncheckedAccount<'info>, + + #[account(mut)] + pub pool_state: Box>, + + #[account(mut)] + pub owner_lp_token: UncheckedAccount<'info>, + + #[account( + mut, + token::mint = vault_0_mint, + token::authority = owner, + )] + pub token_0_account: Box>, + + #[account( + mut, + token::mint = vault_1_mint, + token::authority = owner, + )] + pub token_1_account: Box>, + + #[account( + mut, + constraint = token_0_vault.key() == pool_state.token_0_vault, + )] + pub token_0_vault: Box>, + + #[account( + mut, + constraint = token_1_vault.key() == pool_state.token_1_vault, + )] + pub token_1_vault: Box>, + + #[account(address = pool_state.token_0_mint)] + pub vault_0_mint: Box>, + + #[account(address = pool_state.token_1_mint)] + pub vault_1_mint: Box>, + + #[account( + mut, + constraint = lp_mint.key() == pool_state.lp_mint, + )] + pub lp_mint: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub token_program_2022: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +/// Withdraw instruction handler with BurnCpi. +pub fn process_withdraw(ctx: Context, lp_token_amount: u64) -> Result<()> { + // Burn LP tokens from owner using BurnCpi + BurnCpi { + source: ctx.accounts.owner_lp_token.to_account_info(), + mint: ctx.accounts.lp_mint.to_account_info(), + amount: lp_token_amount, + authority: ctx.accounts.owner.to_account_info(), + max_top_up: None, + } + .invoke()?; + + Ok(()) +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/d5_markers.rs b/sdk-tests/csdk-anchor-full-derived-test/src/d5_markers.rs new file mode 100644 index 0000000000..570ad0bc54 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/d5_markers.rs @@ -0,0 +1,3 @@ +//! Re-export d5_markers from instructions module for top-level access. + +pub use crate::instructions::d5_markers::*; 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 dc76c417d0..cc16540a9b 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 @@ -75,7 +75,7 @@ pub struct CreatePdasAndMintAuto<'info> { mint_signer = mint_signer, authority = mint_authority, decimals = 9, - signer_seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]] + mint_seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]] )] pub cmint: UncheckedAccount<'info>, 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 new file mode 100644 index 0000000000..ffb4619e50 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/mod.rs @@ -0,0 +1,7 @@ +//! D5: Field marker attributes +//! +//! Tests #[rentfree], #[rentfree_token], and #[light_mint] attribute parsing. + +mod rentfree_bare; +// Note rent free custom rightfully is a failing test case not added here. +pub use rentfree_bare::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/rentfree_bare.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/rentfree_bare.rs new file mode 100644 index 0000000000..1bfe5b4da3 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/rentfree_bare.rs @@ -0,0 +1,44 @@ +//! D5 Test: #[rentfree] attribute with #[rentfree_program] macro +//! +//! Tests that the #[rentfree] attribute works correctly when used with the +//! #[rentfree_program] macro on instruction structs in submodules. +//! +//! Note: The params struct must contain `create_accounts_proof: CreateAccountsProof` +//! because the RentFree derive macro generates code that accesses this field. + +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 D5RentfreeBareParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests that #[rentfree] attribute compiles with the #[rentfree_program] macro. +/// The field name can now differ from the type name (e.g., `record` with type `SinglePubkeyRecord`) +/// because the macro now uses the inner_type for seed spec correlation. +#[derive(Accounts, RentFree)] +#[instruction(params: D5RentfreeBareParams)] +pub struct D5RentfreeBare<'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"d5_bare", params.owner.as_ref()], + bump, + )] + #[rentfree] + pub 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 new file mode 100644 index 0000000000..e5f94831ee --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs @@ -0,0 +1,10 @@ +//! Instruction account test cases organized by dimension. +//! +//! Each subdirectory tests a specific macro code path dimension: +//! - d5_markers: Field marker attributes (#[rentfree], #[rentfree_token], #[light_mint]) +//! - d6_account_types: Account type extraction (Account, Box) +//! - d7_infra_names: Infrastructure field naming variations +//! - d8_builder_paths: Builder code generation paths +//! - d9_seeds: Seed expression classification + +pub mod d5_markers; 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 4477e078a5..676abac7cb 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -5,13 +5,20 @@ use light_sdk::{derive_light_cpi_signer, derive_light_rent_sponsor_pda}; use light_sdk_macros::rentfree_program; use light_sdk_types::CpiSigner; +pub mod amm_test; +pub mod d5_markers; pub mod errors; pub mod instruction_accounts; +pub mod instructions; +pub mod processors; pub mod state; - +pub use amm_test::*; +pub use d5_markers::*; pub use instruction_accounts::*; -pub use state::{GameSession, PackedGameSession, PackedUserRecord, PlaceholderRecord, UserRecord}; - +pub use state::{ + d1_field_types::single_pubkey::{PackedSinglePubkeyRecord, SinglePubkeyRecord}, + GameSession, PackedGameSession, PackedUserRecord, PlaceholderRecord, UserRecord, +}; #[inline] pub fn max_key(left: &Pubkey, right: &Pubkey) -> [u8; 32] { if left > right { @@ -41,10 +48,10 @@ pub const GAME_SESSION_SEED: &str = "game_session"; pub mod csdk_anchor_full_derived_test { #![allow(clippy::too_many_arguments)] - use super::*; - use crate::{ + use super::{ + amm_test::{Deposit, InitializeParams, InitializePool, Withdraw}, + d5_markers::{D5RentfreeBare, D5RentfreeBareParams}, instruction_accounts::CreatePdasAndMintAuto, - state::{GameSession, UserRecord}, FullAutoWithMintParams, LIGHT_CPI_SIGNER, }; @@ -131,4 +138,33 @@ pub mod csdk_anchor_full_derived_test { Ok(()) } + + /// Second instruction to test #[rentfree_program] with multiple instructions. + /// Delegates to nested processor in separate module. + pub fn create_single_record<'info>( + ctx: Context<'_, '_, '_, 'info, D5RentfreeBare<'info>>, + params: D5RentfreeBareParams, + ) -> Result<()> { + crate::processors::process_create_single_record(ctx, params) + } + + /// AMM initialize instruction with all rentfree markers. + /// Tests: 2x #[rentfree], 2x #[rentfree_token], 1x #[light_mint], + /// CreateTokenAccountCpi.rent_free(), CreateTokenAtaCpi.rent_free(), MintToCpi + pub fn initialize_pool<'info>( + ctx: Context<'_, '_, '_, 'info, InitializePool<'info>>, + params: InitializeParams, + ) -> Result<()> { + crate::amm_test::process_initialize_pool(ctx, params) + } + + /// AMM deposit instruction with MintToCpi. + pub fn deposit(ctx: Context, lp_token_amount: u64) -> Result<()> { + crate::amm_test::process_deposit(ctx, lp_token_amount) + } + + /// AMM withdraw instruction with BurnCpi. + pub fn withdraw(ctx: Context, lp_token_amount: u64) -> Result<()> { + crate::amm_test::process_withdraw(ctx, lp_token_amount) + } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/processors/create_single_record.rs b/sdk-tests/csdk-anchor-full-derived-test/src/processors/create_single_record.rs new file mode 100644 index 0000000000..e0804c0ea1 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/processors/create_single_record.rs @@ -0,0 +1,17 @@ +//! Processor for create_single_record instruction. + +use anchor_lang::prelude::*; + +use crate::d5_markers::{D5RentfreeBare, D5RentfreeBareParams}; + +/// Process the create_single_record instruction. +/// Called by the instruction handler in the program module. +pub fn process_create_single_record( + ctx: Context<'_, '_, '_, '_, D5RentfreeBare<'_>>, + params: D5RentfreeBareParams, +) -> Result<()> { + let record = &mut ctx.accounts.record; + record.owner = params.owner; + record.counter = 0; + Ok(()) +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/processors/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/processors/mod.rs new file mode 100644 index 0000000000..b6b1504d17 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/processors/mod.rs @@ -0,0 +1,9 @@ +//! Processor functions called by instruction handlers. +//! +//! This module demonstrates the nested processor pattern where +//! instruction handlers in the program module delegate to +//! processor functions in separate modules. + +mod create_single_record; + +pub use create_single_record::process_create_single_record; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/all.rs new file mode 100644 index 0000000000..9690c5cf2f --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/all.rs @@ -0,0 +1,36 @@ +//! D1 Test: ALL field types combined +//! +//! Exercises all field type code paths in a single struct: +//! - Multiple Pubkeys (-> u8 indices) +//! - Option (-> Option) +//! - String (-> clone() path) +//! - Arrays (-> direct copy) +//! - Option (-> unchanged) + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// Comprehensive struct with all field type variations. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct AllFieldTypesRecord { + pub compression_info: Option, + // Multiple Pubkeys -> _index: u8 fields + pub owner: Pubkey, + pub delegate: Pubkey, + pub authority: Pubkey, + // Option -> Option + pub close_authority: Option, + // String -> clone() path + #[max_len(64)] + pub name: String, + // Arrays -> direct copy + pub hash: [u8; 32], + // Option -> unchanged + pub end_time: Option, + pub enabled: Option, + // Regular primitives + pub counter: u64, + pub flag: bool, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/arrays.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/arrays.rs new file mode 100644 index 0000000000..10c6ea2233 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/arrays.rs @@ -0,0 +1,18 @@ +//! D1 Test: Array fields - [u8; 32], [u8; 8] +//! +//! Exercises the code path for array field handling. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with array fields. +/// Tests [u8; 32] (byte array) and fixed-size arrays. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct ArrayRecord { + pub compression_info: Option, + pub hash: [u8; 32], + pub short_data: [u8; 8], + pub counter: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/mod.rs new file mode 100644 index 0000000000..3273985fe3 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/mod.rs @@ -0,0 +1,12 @@ +//! D1: Field type variations +//! +//! Tests `is_pubkey_type()`, `is_copy_type()`, and Pack generation code paths. + +pub mod all; +pub mod arrays; +pub mod multi_pubkey; +pub mod no_pubkey; +pub mod non_copy; +pub mod option_primitive; +pub mod option_pubkey; +pub mod single_pubkey; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/multi_pubkey.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/multi_pubkey.rs new file mode 100644 index 0000000000..98c506a1e9 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/multi_pubkey.rs @@ -0,0 +1,20 @@ +//! D1 Test: Multiple Pubkey fields - PackedX with multiple u8 indices +//! +//! Exercises the code path where 3+ Pubkey fields exist, +//! generating a PackedX struct with multiple u8 index fields. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with multiple Pubkey fields. +/// PackedMultiPubkeyRecord will have: owner_index, delegate_index, authority_index: u8 +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct MultiPubkeyRecord { + pub compression_info: Option, + pub owner: Pubkey, + pub delegate: Pubkey, + pub authority: Pubkey, + pub amount: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/no_pubkey.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/no_pubkey.rs new file mode 100644 index 0000000000..98822f70fd --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/no_pubkey.rs @@ -0,0 +1,19 @@ +//! D1 Test: No Pubkey fields - Identity Pack generation +//! +//! Exercises the code path where no Pubkey fields exist, +//! resulting in Pack/Unpack being a type alias (identity). + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with only primitive fields - no Pubkey. +/// This tests the identity Pack path where PackedNoPubkeyRecord = NoPubkeyRecord. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct NoPubkeyRecord { + pub compression_info: Option, + pub counter: u64, + pub flag: bool, + pub value: u32, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/non_copy.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/non_copy.rs new file mode 100644 index 0000000000..6822618d70 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/non_copy.rs @@ -0,0 +1,21 @@ +//! D1 Test: Non-Copy field (String) - clone() path +//! +//! Exercises the code path where a non-Copy field (String) exists, +//! which triggers the `.clone()` path in pack/unpack generation. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with a String field (non-Copy type). +/// This tests the clone() code path for non-Copy fields. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct NonCopyRecord { + pub compression_info: Option, + #[max_len(64)] + pub name: String, + #[max_len(128)] + pub description: String, + pub counter: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/option_primitive.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/option_primitive.rs new file mode 100644 index 0000000000..bdb5bdf6a2 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/option_primitive.rs @@ -0,0 +1,20 @@ +//! D1 Test: Option fields +//! +//! Exercises the code path where Option, Option, etc. exist. +//! These remain unchanged in the packed struct (not converted to u8 index). + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with Option fields. +/// These stay as Option in the packed struct (not Option). +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct OptionPrimitiveRecord { + pub compression_info: Option, + pub counter: u64, + pub end_time: Option, + pub enabled: Option, + pub score: Option, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/option_pubkey.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/option_pubkey.rs new file mode 100644 index 0000000000..0a2ddf29bf --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/option_pubkey.rs @@ -0,0 +1,20 @@ +//! D1 Test: Option field +//! +//! Exercises the code path where Option fields exist, +//! which generates Option in the packed struct. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with Option fields. +/// PackedOptionPubkeyRecord will have: delegate_index: Option +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct OptionPubkeyRecord { + pub compression_info: Option, + pub owner: Pubkey, + pub delegate: Option, + pub close_authority: Option, + pub amount: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/single_pubkey.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/single_pubkey.rs new file mode 100644 index 0000000000..e0c1c26f61 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d1_field_types/single_pubkey.rs @@ -0,0 +1,18 @@ +//! D1 Test: Single Pubkey field - PackedX with one u8 index +//! +//! Exercises the code path where exactly one Pubkey field exists, +//! generating a PackedX struct with a single u8 index field. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with exactly one Pubkey field. +/// PackedSinglePubkeyRecord will have: owner_index: u8 +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct SinglePubkeyRecord { + pub compression_info: Option, + pub owner: Pubkey, + pub counter: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/absent.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/absent.rs new file mode 100644 index 0000000000..a519ab94c3 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/absent.rs @@ -0,0 +1,19 @@ +//! D2 Test: compress_as attribute absent +//! +//! Exercises the code path where no #[compress_as] attribute is present. +//! All fields use self.field directly for compression. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct without any compress_as attribute. +/// All fields are compressed as-is using self.field. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct NoCompressAsRecord { + pub compression_info: Option, + pub owner: Pubkey, + pub counter: u64, + pub flag: bool, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/all.rs new file mode 100644 index 0000000000..f56d3a75de --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/all.rs @@ -0,0 +1,28 @@ +//! D2 Test: ALL compress_as variations combined +//! +//! Exercises all compress_as code paths in a single struct: +//! - Multiple literal overrides (0) +//! - Option field override (None) +//! - Fields without override (use self.field) + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// Comprehensive struct with all compress_as variations. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[compress_as(time = 0, end = None, score = 0, cached = 0)] +#[account] +pub struct AllCompressAsRecord { + pub compression_info: Option, + pub owner: Pubkey, + // Override with 0 + pub time: u64, + pub score: u64, + pub cached: u64, + // Override with None + pub end: Option, + // No override - uses self.field + pub counter: u64, + pub flag: bool, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/mod.rs new file mode 100644 index 0000000000..68fb05acee --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/mod.rs @@ -0,0 +1,9 @@ +//! D2: compress_as attribute variations +//! +//! Tests override value parsing in traits.rs. + +pub mod absent; +pub mod all; +pub mod multiple; +pub mod option_none; +pub mod single; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/multiple.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/multiple.rs new file mode 100644 index 0000000000..9ce81f05b7 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/multiple.rs @@ -0,0 +1,21 @@ +//! D2 Test: compress_as with multiple overrides +//! +//! Exercises the code path where multiple fields have compress_as overrides. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with multiple compress_as overrides. +/// start, score, and cached all have compression overrides. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[compress_as(start = 0, score = 0, cached = 0)] +#[account] +pub struct MultipleCompressAsRecord { + pub compression_info: Option, + pub owner: Pubkey, + pub start: u64, + pub score: u64, + pub cached: u64, + pub counter: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/option_none.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/option_none.rs new file mode 100644 index 0000000000..701ec3b7aa --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/option_none.rs @@ -0,0 +1,20 @@ +//! D2 Test: compress_as with None value for Option fields +//! +//! Exercises the code path where Option fields are compressed as None. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with compress_as None for Option fields. +/// end_time is compressed as None instead of self.end_time. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[compress_as(end_time = None)] +#[account] +pub struct OptionNoneCompressAsRecord { + pub compression_info: Option, + pub owner: Pubkey, + pub start_time: u64, + pub end_time: Option, + pub counter: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/single.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/single.rs new file mode 100644 index 0000000000..f6916a0ae6 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d2_compress_as/single.rs @@ -0,0 +1,19 @@ +//! D2 Test: compress_as with single override +//! +//! Exercises the code path where one field has a compress_as override. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// A struct with single compress_as override. +/// cached field is compressed as 0 instead of self.cached. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[compress_as(cached = 0)] +#[account] +pub struct SingleCompressAsRecord { + pub compression_info: Option, + pub owner: Pubkey, + pub cached: u64, + pub counter: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/all.rs new file mode 100644 index 0000000000..303b5500b0 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/all.rs @@ -0,0 +1,33 @@ +//! D4 Test: ALL composition variations combined +//! +//! Exercises a large struct with all field type variants from D1. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// Comprehensive large struct with all field types. +/// 15+ fields to trigger SHA256 mode with all D1 variations. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[compress_as(cached_time = 0, end_time = None)] +#[account] +pub struct AllCompositionRecord { + // compression_info in middle position + pub owner: Pubkey, + pub delegate: Pubkey, + pub compression_info: Option, + pub authority: Pubkey, + pub close_authority: Option, + #[max_len(64)] + pub name: String, + pub hash: [u8; 32], + pub start_time: u64, + pub cached_time: u64, + pub end_time: Option, + pub counter_1: u64, + pub counter_2: u64, + pub counter_3: u64, + pub flag_1: bool, + pub flag_2: bool, + pub score: Option, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/info_last.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/info_last.rs new file mode 100644 index 0000000000..707f8cd5cd --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/info_last.rs @@ -0,0 +1,18 @@ +//! D4 Test: compression_info as last field +//! +//! Exercises struct validation with compression_info in non-first position. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// Struct with compression_info as last field. +/// Tests that field ordering is handled correctly. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct InfoLastRecord { + pub owner: Pubkey, + pub counter: u64, + pub flag: bool, + pub compression_info: Option, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/large.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/large.rs new file mode 100644 index 0000000000..f940d4eaeb --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/large.rs @@ -0,0 +1,26 @@ +//! D4 Test: Large struct with many fields +//! +//! Exercises the hash mode selection for large structs (SHA256 path). + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// Large struct with 12+ fields for SHA256 hash mode. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct LargeRecord { + pub compression_info: Option, + pub field_01: u64, + pub field_02: u64, + pub field_03: u64, + pub field_04: u64, + pub field_05: u64, + pub field_06: u64, + pub field_07: u64, + pub field_08: u64, + pub field_09: u64, + pub field_10: u64, + pub field_11: u64, + pub field_12: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/minimal.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/minimal.rs new file mode 100644 index 0000000000..577b71fd03 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/minimal.rs @@ -0,0 +1,15 @@ +//! D4 Test: Minimal valid struct +//! +//! Exercises the smallest valid struct with compression_info and one field. + +use anchor_lang::prelude::*; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk_macros::RentFreeAccount; + +/// Smallest valid struct: compression_info + one field. +#[derive(Default, Debug, InitSpace, RentFreeAccount)] +#[account] +pub struct MinimalRecord { + pub compression_info: Option, + pub value: u64, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/mod.rs new file mode 100644 index 0000000000..6dd8e038a2 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/d4_composition/mod.rs @@ -0,0 +1,8 @@ +//! D4: Struct composition and Pack generation +//! +//! Tests struct validation and size-based hash mode selection. + +pub mod all; +pub mod info_last; +pub mod large; +pub mod minimal; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state/mod.rs similarity index 88% rename from sdk-tests/csdk-anchor-full-derived-test/src/state.rs rename to sdk-tests/csdk-anchor-full-derived-test/src/state/mod.rs index 2a8884fb96..8354724d0f 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/state.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state/mod.rs @@ -1,3 +1,5 @@ +//! State structs for the test program and test cases organized by dimension. + use anchor_lang::prelude::*; use light_sdk::{ compressible::CompressionInfo, instruction::PackedAddressTreeInfo, LightDiscriminator, @@ -6,6 +8,13 @@ use light_sdk_macros::RentFreeAccount; use light_token_interface::instructions::mint_action::MintWithContext; use light_token_sdk::ValidityProof; +// Test modules +pub mod d1_field_types; +pub mod d2_compress_as; +pub mod d4_composition; + +// Original state types used by the main program + #[derive(Default, Debug, InitSpace, RentFreeAccount)] #[account] pub struct UserRecord { 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 new file mode 100644 index 0000000000..013ecb25ef --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -0,0 +1,629 @@ +/// AMM Full Lifecycle Integration Test +/// +/// Tests the complete AMM flow: +/// 1. Initialize pool with rent-free PDAs and LP mint +/// 2. Deposit tokens and receive LP tokens +/// 3. Withdraw tokens by burning LP tokens +/// 4. Advance epochs to trigger auto-compression +/// 5. Decompress all accounts +/// 6. Deposit after decompression to verify pool works +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 light_compressible::rent::SLOTS_PER_EPOCH; +use light_compressible_client::{ + get_create_accounts_proof, CreateAccountsProofInput, InitializeRentFreeConfig, +}; +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_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, +}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +const RENT_SPONSOR: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); + +// ============================================================================= +// Assertion Helpers +// ============================================================================= + +async fn assert_onchain_exists(rpc: &mut LightProgramTest, pda: &Pubkey) { + assert!( + rpc.get_account(*pda).await.unwrap().is_some(), + "Account {} should exist on-chain", + pda + ); +} + +async fn assert_onchain_closed(rpc: &mut LightProgramTest, pda: &Pubkey) { + let acc = rpc.get_account(*pda).await.unwrap(); + assert!( + acc.is_none() || acc.unwrap().lamports == 0, + "Account {} should be closed", + pda + ); +} + +fn parse_token(data: &[u8]) -> Token { + borsh::BorshDeserialize::deserialize(&mut &data[..]).unwrap() +} + +async fn assert_compressed_exists_with_data(rpc: &mut LightProgramTest, addr: [u8; 32]) { + let acc = 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()); +} + +async fn assert_compressed_token_exists( + rpc: &mut LightProgramTest, + owner: &Pubkey, + expected_amount: u64, +) { + let accs = rpc + .get_compressed_token_accounts_by_owner(owner, None, None) + .await + .unwrap() + .value + .items; + assert!(!accs.is_empty(), "Compressed token account should exist"); + assert_eq!( + accs[0].token.amount, expected_amount, + "Compressed token amount mismatch" + ); +} + +/// Stores all AMM-related PDAs +struct AmmPdas { + pool_state: Pubkey, + #[allow(dead_code)] + pool_state_bump: u8, + observation_state: Pubkey, + #[allow(dead_code)] + observation_state_bump: u8, + authority: Pubkey, + #[allow(dead_code)] + authority_bump: u8, + token_0_vault: Pubkey, + #[allow(dead_code)] + token_0_vault_bump: u8, + token_1_vault: Pubkey, + #[allow(dead_code)] + token_1_vault_bump: u8, + lp_mint_signer: Pubkey, + lp_mint_signer_bump: u8, + lp_mint: Pubkey, + creator_lp_token: Pubkey, + creator_lp_token_bump: u8, +} + +/// Context for AMM tests +struct AmmTestContext { + rpc: LightProgramTest, + payer: Keypair, + config_pda: Pubkey, + program_id: Pubkey, + token_0_mint: Pubkey, + token_1_mint: Pubkey, + creator: Keypair, + creator_token_0: Pubkey, + creator_token_1: Pubkey, + amm_config: Keypair, +} + +/// Setup the test environment with light mints +async fn setup() -> AmmTestContext { + 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(); + + // Setup mock program data and compression config + 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"); + + // Create creator keypair and fund + let creator = Keypair::new(); + light_test_utils::airdrop_lamports(&mut rpc, &creator.pubkey(), 10_000_000_000) + .await + .unwrap(); + + // Create two light mints (cmints) for token_0 and token_1 + // Using shared::setup_create_mint which creates both compressed mint and on-chain Mint account + let (mint_a, _compression_addr_a, ata_pubkeys_a, _mint_seed_a) = shared::setup_create_mint( + &mut rpc, + &payer, + payer.pubkey(), // mint_authority + 9, // decimals + vec![(10_000_000, creator.pubkey())], // mint to creator + ) + .await; + + let (mint_b, _compression_addr_b, ata_pubkeys_b, _mint_seed_b) = shared::setup_create_mint( + &mut rpc, + &payer, + payer.pubkey(), // mint_authority + 9, // decimals + vec![(10_000_000, creator.pubkey())], // mint to creator + ) + .await; + + // Ensure proper ordering: token_0_mint.key() < token_1_mint.key() + let (token_0_mint, token_1_mint, creator_token_0, creator_token_1) = if mint_a < mint_b { + (mint_a, mint_b, ata_pubkeys_a[0], ata_pubkeys_b[0]) + } else { + (mint_b, mint_a, ata_pubkeys_b[0], ata_pubkeys_a[0]) + }; + + // Create amm_config account (simple funded account for this test) + let amm_config = Keypair::new(); + light_test_utils::airdrop_lamports(&mut rpc, &amm_config.pubkey(), 1_000_000) + .await + .unwrap(); + + AmmTestContext { + rpc, + payer, + config_pda, + program_id, + token_0_mint, + token_1_mint, + creator, + creator_token_0, + creator_token_1, + amm_config, + } +} + +/// Derive all AMM PDAs +fn derive_amm_pdas( + program_id: &Pubkey, + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + creator: &Pubkey, +) -> AmmPdas { + // Pool state: seeds = [POOL_SEED, amm_config, token_0_mint, token_1_mint] + let (pool_state, pool_state_bump) = Pubkey::find_program_address( + &[ + POOL_SEED.as_bytes(), + amm_config.as_ref(), + token_0_mint.as_ref(), + token_1_mint.as_ref(), + ], + program_id, + ); + + // Authority: seeds = [AUTH_SEED] + let (authority, authority_bump) = + Pubkey::find_program_address(&[AUTH_SEED.as_bytes()], program_id); + + // Observation: seeds = [OBSERVATION_SEED, pool_state] + let (observation_state, observation_state_bump) = Pubkey::find_program_address( + &[OBSERVATION_SEED.as_bytes(), pool_state.as_ref()], + program_id, + ); + + // Vault 0: seeds = [POOL_VAULT_SEED, pool_state, token_0_mint] + let (token_0_vault, token_0_vault_bump) = Pubkey::find_program_address( + &[ + POOL_VAULT_SEED.as_bytes(), + pool_state.as_ref(), + token_0_mint.as_ref(), + ], + program_id, + ); + + // Vault 1: seeds = [POOL_VAULT_SEED, pool_state, token_1_mint] + let (token_1_vault, token_1_vault_bump) = Pubkey::find_program_address( + &[ + POOL_VAULT_SEED.as_bytes(), + pool_state.as_ref(), + token_1_mint.as_ref(), + ], + program_id, + ); + + // LP mint signer: seeds = [POOL_LP_MINT_SIGNER_SEED, pool_state] + let (lp_mint_signer, lp_mint_signer_bump) = + Pubkey::find_program_address(&[POOL_LP_MINT_SIGNER_SEED, pool_state.as_ref()], program_id); + + // LP mint: derived from lp_mint_signer using find_mint_address + let (lp_mint, _) = find_mint_address(&lp_mint_signer); + + // Creator LP token ATA: using get_associated_token_address_and_bump + let (creator_lp_token, creator_lp_token_bump) = + get_associated_token_address_and_bump(creator, &lp_mint); + + AmmPdas { + pool_state, + pool_state_bump, + observation_state, + observation_state_bump, + authority, + authority_bump, + token_0_vault, + token_0_vault_bump, + token_1_vault, + token_1_vault_bump, + lp_mint_signer, + lp_mint_signer_bump, + lp_mint, + creator_lp_token, + creator_lp_token_bump, + } +} + +/// AMM full lifecycle integration test +#[tokio::test] +async fn test_amm_full_lifecycle() { + // ========================================================================== + // PHASE 1: Setup + // ========================================================================== + let mut ctx = setup().await; + + // ========================================================================== + // PHASE 2: Derive PDAs + // ========================================================================== + let pdas = derive_amm_pdas( + &ctx.program_id, + &ctx.amm_config.pubkey(), + &ctx.token_0_mint, + &ctx.token_1_mint, + &ctx.creator.pubkey(), + ); + + println!("Derived PDAs:"); + println!(" pool_state: {}", pdas.pool_state); + println!(" observation_state: {}", pdas.observation_state); + println!(" authority: {}", pdas.authority); + println!(" token_0_vault: {}", pdas.token_0_vault); + println!(" token_1_vault: {}", pdas.token_1_vault); + println!(" lp_mint_signer: {}", pdas.lp_mint_signer); + println!(" lp_mint: {}", pdas.lp_mint); + println!(" creator_lp_token: {}", pdas.creator_lp_token); + + // ========================================================================== + // PHASE 3: Get create accounts proof + // ========================================================================== + let proof_result = get_create_accounts_proof( + &ctx.rpc, + &ctx.program_id, + vec![ + CreateAccountsProofInput::pda(pdas.pool_state), + CreateAccountsProofInput::pda(pdas.observation_state), + CreateAccountsProofInput::mint(pdas.lp_mint_signer), + ], + ) + .await + .unwrap(); + + // ========================================================================== + // PHASE 4: Initialize Pool + // ========================================================================== + let init_amount_0 = 1000u64; + let init_amount_1 = 1000u64; + let open_time = 0u64; + + let init_params = InitializeParams { + init_amount_0, + init_amount_1, + open_time, + create_accounts_proof: proof_result.create_accounts_proof, + lp_mint_signer_bump: pdas.lp_mint_signer_bump, + creator_lp_token_bump: pdas.creator_lp_token_bump, + authority_bump: pdas.authority_bump, + }; + + let accounts = csdk_anchor_full_derived_test::accounts::InitializePool { + creator: ctx.creator.pubkey(), + amm_config: ctx.amm_config.pubkey(), + authority: pdas.authority, + pool_state: pdas.pool_state, + token_0_mint: ctx.token_0_mint, + token_1_mint: ctx.token_1_mint, + lp_mint_signer: pdas.lp_mint_signer, + lp_mint: pdas.lp_mint, + creator_token_0: ctx.creator_token_0, + creator_token_1: ctx.creator_token_1, + creator_lp_token: pdas.creator_lp_token, + token_0_vault: pdas.token_0_vault, + token_1_vault: pdas.token_1_vault, + observation_state: pdas.observation_state, + token_program: LIGHT_TOKEN_PROGRAM_ID, + token_0_program: LIGHT_TOKEN_PROGRAM_ID, + token_1_program: LIGHT_TOKEN_PROGRAM_ID, + associated_token_program: LIGHT_TOKEN_PROGRAM_ID, + system_program: solana_sdk::system_program::ID, + rent: solana_sdk::sysvar::rent::ID, + compression_config: ctx.config_pda, + ctoken_compressible_config: COMPRESSIBLE_CONFIG_V1, + ctoken_rent_sponsor: LIGHT_TOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID, + ctoken_cpi_authority: LIGHT_TOKEN_CPI_AUTHORITY, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::InitializePool { + params: init_params, + }; + + 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, &ctx.creator], + ) + .await + .expect("Initialize pool should succeed"); + + // ========================================================================== + // PHASE 5: Verify Initial State (assert_after_initialize) + // ========================================================================== + 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 creator LP token balance (should have initial LP amount from initialize) + let lp_token_data = parse_token( + &ctx.rpc + .get_account(pdas.creator_lp_token) + .await + .unwrap() + .unwrap() + .data, + ); + let initial_lp_balance = lp_token_data.amount; + assert!( + initial_lp_balance > 0, + "Creator should have received LP tokens" + ); + println!("Initial LP balance: {}", initial_lp_balance); + + // ========================================================================== + // PHASE 6: Deposit + // ========================================================================== + let deposit_amount = 500u64; + + let deposit_accounts = csdk_anchor_full_derived_test::accounts::Deposit { + owner: ctx.creator.pubkey(), + authority: pdas.authority, + pool_state: pdas.pool_state, + owner_lp_token: pdas.creator_lp_token, + token_0_account: ctx.creator_token_0, + token_1_account: ctx.creator_token_1, + token_0_vault: pdas.token_0_vault, + token_1_vault: pdas.token_1_vault, + vault_0_mint: ctx.token_0_mint, + vault_1_mint: ctx.token_1_mint, + lp_mint: pdas.lp_mint, + token_program: LIGHT_TOKEN_PROGRAM_ID, + token_program_2022: LIGHT_TOKEN_PROGRAM_ID, + system_program: solana_sdk::system_program::ID, + }; + + let deposit_instruction_data = csdk_anchor_full_derived_test::instruction::Deposit { + lp_token_amount: deposit_amount, + }; + + let deposit_instruction = Instruction { + program_id: ctx.program_id, + accounts: deposit_accounts.to_account_metas(None), + data: deposit_instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction( + &[deposit_instruction], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.creator], + ) + .await + .expect("Deposit should succeed"); + + // Verify LP balance after deposit (assert_after_deposit) + let lp_token_data_after_deposit = parse_token( + &ctx.rpc + .get_account(pdas.creator_lp_token) + .await + .unwrap() + .unwrap() + .data, + ); + let expected_balance_after_deposit = initial_lp_balance + deposit_amount; + assert_eq!( + lp_token_data_after_deposit.amount, expected_balance_after_deposit, + "LP balance should increase after deposit" + ); + println!( + "LP balance after deposit: {} (expected: {})", + lp_token_data_after_deposit.amount, expected_balance_after_deposit + ); + + // ========================================================================== + // PHASE 7: Withdraw + // ========================================================================== + let withdraw_amount = 200u64; + + let withdraw_accounts = csdk_anchor_full_derived_test::accounts::Withdraw { + owner: ctx.creator.pubkey(), + authority: pdas.authority, + pool_state: pdas.pool_state, + owner_lp_token: pdas.creator_lp_token, + token_0_account: ctx.creator_token_0, + token_1_account: ctx.creator_token_1, + token_0_vault: pdas.token_0_vault, + token_1_vault: pdas.token_1_vault, + vault_0_mint: ctx.token_0_mint, + vault_1_mint: ctx.token_1_mint, + lp_mint: pdas.lp_mint, + token_program: LIGHT_TOKEN_PROGRAM_ID, + token_program_2022: LIGHT_TOKEN_PROGRAM_ID, + system_program: solana_sdk::system_program::ID, + }; + + let withdraw_instruction_data = csdk_anchor_full_derived_test::instruction::Withdraw { + lp_token_amount: withdraw_amount, + }; + + let withdraw_instruction = Instruction { + program_id: ctx.program_id, + accounts: withdraw_accounts.to_account_metas(None), + data: withdraw_instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction( + &[withdraw_instruction], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.creator], + ) + .await + .expect("Withdraw should succeed"); + + // Verify LP balance after withdraw (assert_after_withdraw) + let lp_token_data_after_withdraw = parse_token( + &ctx.rpc + .get_account(pdas.creator_lp_token) + .await + .unwrap() + .unwrap() + .data, + ); + let expected_balance_after_withdraw = expected_balance_after_deposit - withdraw_amount; + assert_eq!( + lp_token_data_after_withdraw.amount, expected_balance_after_withdraw, + "LP balance should decrease after withdraw" + ); + println!( + "LP balance after withdraw: {} (expected: {})", + lp_token_data_after_withdraw.amount, expected_balance_after_withdraw + ); + + // ========================================================================== + // PHASE 8: Advance Epochs (trigger auto-compression) + // ========================================================================== + println!("\nAdvancing epochs to trigger auto-compression..."); + ctx.rpc + .warp_slot_forward(SLOTS_PER_EPOCH * 30) + .await + .unwrap(); + + // Derive compressed addresses for verification + let address_tree_pubkey = ctx.rpc.get_address_tree_v2().tree; + + let pool_compressed_address = light_compressed_account::address::derive_address( + &pdas.pool_state.to_bytes(), + &address_tree_pubkey.to_bytes(), + &ctx.program_id.to_bytes(), + ); + let observation_compressed_address = light_compressed_account::address::derive_address( + &pdas.observation_state.to_bytes(), + &address_tree_pubkey.to_bytes(), + &ctx.program_id.to_bytes(), + ); + let mint_compressed_address = + light_token_sdk::compressed_token::create_compressed_mint::derive_mint_compressed_address( + &pdas.lp_mint_signer, + &address_tree_pubkey, + ); + + // Assert compression (assert_after_compression) + assert_onchain_closed(&mut ctx.rpc, &pdas.pool_state).await; + assert_onchain_closed(&mut ctx.rpc, &pdas.observation_state).await; + assert_onchain_closed(&mut ctx.rpc, &pdas.lp_mint).await; + assert_onchain_closed(&mut ctx.rpc, &pdas.token_0_vault).await; + assert_onchain_closed(&mut ctx.rpc, &pdas.token_1_vault).await; + assert_onchain_closed(&mut ctx.rpc, &pdas.creator_lp_token).await; + + // Verify compressed accounts exist with non-empty data + assert_compressed_exists_with_data(&mut ctx.rpc, pool_compressed_address).await; + assert_compressed_exists_with_data(&mut ctx.rpc, observation_compressed_address).await; + assert_compressed_exists_with_data(&mut ctx.rpc, mint_compressed_address).await; + + // Verify compressed token accounts + assert_compressed_token_exists(&mut ctx.rpc, &pdas.token_0_vault, 0).await; + assert_compressed_token_exists(&mut ctx.rpc, &pdas.token_1_vault, 0).await; + assert_compressed_token_exists( + &mut ctx.rpc, + &pdas.creator_lp_token, + expected_balance_after_withdraw, + ) + .await; + + println!("All accounts compressed successfully!"); + + // ========================================================================== + // 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!("\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)"); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/shared.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/shared.rs new file mode 100644 index 0000000000..200f6e4432 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/shared.rs @@ -0,0 +1,137 @@ +// Shared test utilities for csdk-anchor-full-derived-test + +use light_client::{indexer::Indexer, rpc::Rpc}; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; + +/// Setup helper: Creates a compressed mint directly using the ctoken SDK (not via wrapper program) +/// Optionally creates ATAs and mints tokens for each recipient. +/// Note: This decompresses the mint first, then uses MintTo to mint to ctoken accounts. +/// Returns (mint_pda, compression_address, ata_pubkeys, mint_seed_keypair) +#[allow(unused)] +pub async fn setup_create_mint( + rpc: &mut (impl Rpc + Indexer), + payer: &Keypair, + mint_authority: Pubkey, + decimals: u8, + recipients: Vec<(u64, Pubkey)>, +) -> (Pubkey, [u8; 32], Vec, Keypair) { + use light_token_sdk::token::{ + CreateAssociatedTokenAccount, CreateMint, CreateMintParams, MintTo, + }; + + let mint_seed = Keypair::new(); + let address_tree = rpc.get_address_tree_v2(); + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Derive compression address using SDK helpers + let compression_address = light_token_sdk::token::derive_mint_compressed_address( + &mint_seed.pubkey(), + &address_tree.tree, + ); + + let (mint, bump) = light_token_sdk::token::find_mint_address(&mint_seed.pubkey()); + + // Get validity proof for the address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![light_client::indexer::AddressWithTree { + address: compression_address, + tree: address_tree.tree, + }], + None, + ) + .await + .unwrap() + .value; + + // Build params for the SDK + let params = CreateMintParams { + decimals, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority, + proof: rpc_result.proof.0.unwrap(), + compression_address, + mint, + bump, + freeze_authority: None, + extensions: None, + rent_payment: 16, + write_top_up: 766, + }; + + // Create instruction directly using SDK + let create_mint_builder = CreateMint::new( + params, + mint_seed.pubkey(), + payer.pubkey(), + address_tree.tree, + output_queue, + ); + let instruction = create_mint_builder.instruction().unwrap(); + + // Send transaction + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer, &mint_seed]) + .await + .unwrap(); + + // Verify the compressed mint was created + let compressed_account = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value; + + assert!( + compressed_account.is_some(), + "Compressed mint should exist after setup" + ); + + // If no recipients, return early + if recipients.is_empty() { + return (mint, compression_address, vec![], mint_seed); + } + + // Create ATAs for each recipient + use light_token_sdk::token::derive_token_ata; + + let mut ata_pubkeys = Vec::with_capacity(recipients.len()); + + for (_amount, owner) in &recipients { + let (ata_address, _bump) = derive_token_ata(owner, &mint); + ata_pubkeys.push(ata_address); + + let create_ata = CreateAssociatedTokenAccount::new(payer.pubkey(), *owner, mint); + let ata_instruction = create_ata.instruction().unwrap(); + + rpc.create_and_send_transaction(&[ata_instruction], &payer.pubkey(), &[payer]) + .await + .unwrap(); + } + + // Mint tokens to recipients with amount > 0 + let recipients_with_amount: Vec<_> = recipients + .iter() + .enumerate() + .filter(|(_, (amount, _))| *amount > 0) + .collect(); + + // Mint to each recipient using the decompressed Mint (CreateMint already decompresses) + for (idx, (amount, _)) in &recipients_with_amount { + let mint_instruction = MintTo { + mint, + destination: ata_pubkeys[*idx], + amount: *amount, + authority: mint_authority, + max_top_up: None, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[mint_instruction], &payer.pubkey(), &[payer]) + .await + .unwrap(); + } + + (mint, compression_address, ata_pubkeys, mint_seed) +}