diff --git a/Cargo.lock b/Cargo.lock index a107e70b97..2248251088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4028,6 +4028,7 @@ dependencies = [ "anchor-lang", "bincode", "borsh 0.10.4", + "bytemuck", "light-account-checks", "light-compressed-account", "light-compressible", @@ -4035,6 +4036,7 @@ dependencies = [ "light-hasher", "light-heap", "light-macros", + "light-program-profiler", "light-sdk-macros", "light-sdk-types", "light-zero-copy", @@ -6412,6 +6414,35 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "single-account-loader-test" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "borsh 0.10.4", + "bytemuck", + "light-client", + "light-compressed-account", + "light-compressible", + "light-heap", + "light-program-test", + "light-sdk", + "light-sdk-macros", + "light-sdk-types", + "light-test-utils", + "light-token", + "solana-account-info", + "solana-instruction", + "solana-keypair", + "solana-msg 2.2.1", + "solana-program", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-sdk", + "solana-signer", + "tokio", +] + [[package]] name = "single-ata-test" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index dc679fc404..6122da8fc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ members = [ "sdk-tests/csdk-anchor-full-derived-test-sdk", "sdk-tests/single-mint-test", "sdk-tests/single-pda-test", + "sdk-tests/single-account-loader-test", "sdk-tests/single-ata-test", "sdk-tests/single-token-test", "forester-utils", diff --git a/sdk-libs/macros/CLAUDE.md b/sdk-libs/macros/CLAUDE.md index 52c0ea1181..16222b9f92 100644 --- a/sdk-libs/macros/CLAUDE.md +++ b/sdk-libs/macros/CLAUDE.md @@ -14,7 +14,7 @@ This crate provides macros that enable rent-free compressed accounts on Solana w | Macro | Type | Purpose | |-------|------|---------| | `#[derive(LightAccounts)]` | Derive | Generates `LightPreInit`/`LightFinalize` for Accounts structs | -| `#[rentfree_program]` | Attribute | Program-level auto-discovery and instruction generation | +| `#[light_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 | @@ -25,7 +25,7 @@ Detailed macro documentation is in the `docs/` directory: - **`docs/CLAUDE.md`** - Documentation structure guide - **`docs/rentfree.md`** - `#[derive(LightAccounts)]` and trait derives -- **`docs/rentfree_program/`** - `#[rentfree_program]` attribute macro (architecture.md + codegen.md) +- **`docs/light_program/`** - `#[light_program]` attribute macro (architecture.md + codegen.md) ## Source Structure @@ -35,7 +35,7 @@ src/ ├── rentfree/ # LightAccounts macro system │ ├── account/ # Trait derive macros for account data structs │ ├── accounts/ # #[derive(LightAccounts)] for Accounts structs -│ ├── program/ # #[rentfree_program] attribute macro +│ ├── program/ # #[light_program] attribute macro │ └── shared_utils.rs # Common utilities └── hasher/ # LightHasherSha derive macro ``` @@ -43,7 +43,7 @@ src/ ## Usage Example ```rust -use light_sdk_macros::{rentfree_program, LightAccounts, LightCompressible}; +use light_sdk_macros::{light_program, LightAccounts, LightCompressible}; // State account with compression support #[derive(Default, Debug, InitSpace, LightCompressible)] @@ -67,7 +67,7 @@ pub struct Create<'info> { } // Program with auto-wrapped instructions -#[rentfree_program] +#[light_program] #[program] pub mod my_program { pub fn create(ctx: Context, params: CreateParams) -> Result<()> { diff --git a/sdk-libs/macros/docs/CLAUDE.md b/sdk-libs/macros/docs/CLAUDE.md index 9874cd0c55..173e1f474a 100644 --- a/sdk-libs/macros/docs/CLAUDE.md +++ b/sdk-libs/macros/docs/CLAUDE.md @@ -11,9 +11,9 @@ Documentation for the rentfree macro system in `light-sdk-macros`. These macros | **`CLAUDE.md`** | This file - documentation structure guide | | **`../CLAUDE.md`** | Main entry point for sdk-libs/macros | | **`rentfree.md`** | `#[derive(LightAccounts)]` 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) | +| **`light_program/`** | `#[light_program]` attribute macro | +| **`light_program/architecture.md`** | Architecture overview, usage, generated items | +| **`light_program/codegen.md`** | Technical implementation details (code generation) | | **`accounts/`** | Field-level attributes for Accounts structs | | **`account/`** | Trait derive macros for account data structs | @@ -43,13 +43,13 @@ See also: `#[light_account(init)]` attribute documented in `rentfree.md` - **Data struct traits**: Start with `account/light_compressible.md` for the all-in-one derive macro for compressible data structs - **Building account structs**: Use `rentfree.md` for the accounts-level derive macro that marks fields for compression -- **Program-level integration**: Use `rentfree_program/architecture.md` for program-level auto-discovery and instruction generation -- **Implementation details**: Use `rentfree_program/codegen.md` for technical code generation details +- **Program-level integration**: Use `light_program/architecture.md` for program-level auto-discovery and instruction generation +- **Implementation details**: Use `light_program/codegen.md` for technical code generation details ### Macro Hierarchy ``` -#[rentfree_program] <- Program-level (rentfree_program/) +#[light_program] <- Program-level (light_program/) | +-- Discovers #[derive(LightAccounts)] structs | @@ -77,7 +77,7 @@ See also: `#[light_account(init)]` attribute documented in `rentfree.md` sdk-libs/macros/src/rentfree/ ├── account/ # Trait derive macros for account data structs ├── accounts/ # #[derive(LightAccounts)] implementation -├── program/ # #[rentfree_program] implementation +├── program/ # #[light_program] implementation ├── shared_utils.rs # Common utilities └── mod.rs # Module exports ``` diff --git a/sdk-libs/macros/docs/accounts/architecture.md b/sdk-libs/macros/docs/accounts/architecture.md index aa3362daf9..70f779fbc7 100644 --- a/sdk-libs/macros/docs/accounts/architecture.md +++ b/sdk-libs/macros/docs/accounts/architecture.md @@ -586,6 +586,6 @@ When no `#[instruction]` attribute is present, the macro generates no-op impleme ## 6. Related Documentation -- **`sdk-libs/macros/docs/rentfree_program/`** - Program-level `#[rentfree_program]` attribute macro (architecture.md + codegen.md) +- **`sdk-libs/macros/docs/light_program/`** - Program-level `#[light_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/features/comparison.md b/sdk-libs/macros/docs/features/comparison.md index 43c854b9e8..309059aa6b 100644 --- a/sdk-libs/macros/docs/features/comparison.md +++ b/sdk-libs/macros/docs/features/comparison.md @@ -207,7 +207,7 @@ pub struct MyData { 1. **Add derives**: Add `RentFree`, `Compressible`, `HasCompressionInfo` 2. **Add compression_info**: Add field to data structs 3. **Add compress_as**: Annotate fields for hashing -4. **Update program attribute**: Add `#[rentfree_program]` +4. **Update program attribute**: Add `#[light_program]` 5. **Add Light accounts**: Include protocol programs in accounts struct 6. **Update token handling**: Convert `mint::*` to `#[light_account(init)]` diff --git a/sdk-libs/macros/docs/features/light-features.md b/sdk-libs/macros/docs/features/light-features.md index 1b42d0b4e8..30eb8fe429 100644 --- a/sdk-libs/macros/docs/features/light-features.md +++ b/sdk-libs/macros/docs/features/light-features.md @@ -127,7 +127,7 @@ pub struct CreateMint<'info> { --- -### 5. `#[rentfree_program]` +### 5. `#[light_program]` **Purpose**: Program-level attribute that generates compression lifecycle hooks. @@ -139,7 +139,7 @@ pub struct CreateMint<'info> { **Example**: ```rust -#[rentfree_program] +#[light_program] #[program] pub mod my_program { use super::*; @@ -463,7 +463,7 @@ pub struct UserProfile { pub compression_info: CompressionInfo, } -#[rentfree_program] +#[light_program] #[program] pub mod my_program { use super::*; diff --git a/sdk-libs/macros/docs/light_program/architecture.md b/sdk-libs/macros/docs/light_program/architecture.md index 727f9f08ff..d4ef280cb6 100644 --- a/sdk-libs/macros/docs/light_program/architecture.md +++ b/sdk-libs/macros/docs/light_program/architecture.md @@ -1,8 +1,8 @@ -# `#[rentfree_program]` Attribute Macro +# `#[light_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. +The `#[light_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/` @@ -10,7 +10,7 @@ The `#[rentfree_program]` attribute macro provides program-level auto-discovery | Location | Macro | Purpose | |----------|-------|---------| -| Program module | `#[rentfree_program]` | Discovers fields, generates instructions, wraps handlers | +| Program module | `#[light_program]` | Discovers fields, generates instructions, wraps handlers | | Accounts struct | `#[derive(LightAccounts)]` | Generates `LightPreInit`/`LightFinalize` trait impls | | Account field | `#[light_account(init)]` | Marks PDA for compression | | Account field | `#[light_account(token, authority=[...])]` | Marks token account for compression | @@ -39,7 +39,7 @@ The `#[rentfree_program]` attribute macro provides program-level auto-discovery The macro reads your crate at compile time to find compressible accounts: ``` -#[rentfree_program] +#[light_program] #[program] pub mod my_program { pub mod accounts; <-- Macro follows this to accounts.rs diff --git a/sdk-libs/macros/docs/light_program/codegen.md b/sdk-libs/macros/docs/light_program/codegen.md index 1b322e046f..f8c0337563 100644 --- a/sdk-libs/macros/docs/light_program/codegen.md +++ b/sdk-libs/macros/docs/light_program/codegen.md @@ -1,13 +1,13 @@ -# `#[rentfree_program]` Code Generation +# `#[light_program]` Code Generation -Technical implementation details for the `#[rentfree_program]` attribute macro. +Technical implementation details for the `#[light_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() +|-- mod.rs # Module exports, main entry point light_program_impl +|-- instructions.rs # Main orchestration: codegen(), light_program_impl() |-- parsing.rs # Core types (TokenSeedSpec, SeedElement, InstructionDataSpec) | # Expression analysis, seed conversion, function wrapping |-- compress.rs # CompressAccountsIdempotent generation @@ -44,11 +44,11 @@ sdk-libs/macros/src/rentfree/ ## 2. Code Generation Flow ``` - #[rentfree_program] + #[light_program] | v +-----------------------------+ - | rentfree_program_impl() | + | light_program_impl() | | (instructions.rs:405) | +-----------------------------+ | diff --git a/sdk-libs/macros/src/lib.rs b/sdk-libs/macros/src/lib.rs index 427b8dc614..dd40c5b6da 100644 --- a/sdk-libs/macros/src/lib.rs +++ b/sdk-libs/macros/src/lib.rs @@ -452,3 +452,43 @@ pub fn light_accounts_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); into_token_stream(light_pdas::accounts::derive_light_accounts(input)) } + +/// Derives PodCompressionInfoField for Pod (zero-copy) structs. +/// +/// This derive macro generates the `PodCompressionInfoField` trait implementation +/// for structs that use zero-copy serialization via `bytemuck::Pod`. +/// +/// ## Requirements +/// +/// 1. The struct must have `#[repr(C)]` attribute for predictable field layout +/// 2. The struct must have a `compression_info: CompressionInfo` field +/// (non-optional, using `light_compressible::compression_info::CompressionInfo`) +/// 3. The struct must implement `bytemuck::Pod` and `bytemuck::Zeroable` +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk_macros::PodCompressionInfoField; +/// use light_compressible::compression_info::CompressionInfo; +/// use bytemuck::{Pod, Zeroable}; +/// +/// #[derive(Clone, Copy, Pod, Zeroable, PodCompressionInfoField)] +/// #[repr(C)] +/// pub struct MyPodAccount { +/// pub owner: [u8; 32], +/// pub data: u64, +/// pub compression_info: CompressionInfo, +/// } +/// ``` +/// +/// ## Differences from Borsh Compression +/// +/// - Pod accounts use non-optional `CompressionInfo` (compression state is indicated +/// by `config_account_version`: 0 = uninitialized, >= 1 = initialized) +/// - Uses `core::mem::offset_of!()` for compile-time offset calculation +/// - More efficient for fixed-size accounts with zero-copy serialization +#[proc_macro_derive(PodCompressionInfoField)] +pub fn pod_compression_info_field(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + into_token_stream(light_pdas::account::traits::derive_pod_compression_info_field(input)) +} diff --git a/sdk-libs/macros/src/light_pdas/README.md b/sdk-libs/macros/src/light_pdas/README.md index e2f290d336..465942e7ba 100644 --- a/sdk-libs/macros/src/light_pdas/README.md +++ b/sdk-libs/macros/src/light_pdas/README.md @@ -12,8 +12,8 @@ rentfree/ │ ├── mod.rs # Entry point: derive_rentfree() │ ├── parse.rs # Parsing #[light_account(init)], #[light_account(init)] attributes │ └── codegen.rs # LightPreInit/LightFinalize trait generation -├── program/ # #[rentfree_program] implementation -│ ├── mod.rs # Entry point: rentfree_program_impl() +├── program/ # #[light_program] implementation +│ ├── mod.rs # Entry point: light_program_impl() │ ├── instructions.rs # Instruction generation and handler wrapping │ ├── crate_context.rs # Crate scanning for #[derive(Accounts)] structs │ ├── variant_enum.rs # LightAccountVariant enum generation @@ -39,7 +39,7 @@ Implements `#[derive(LightAccounts)]` for Anchor Accounts structs: ### `program/` - RentFree Program Macro -Implements `#[rentfree_program]` attribute macro: +Implements `#[light_program]` attribute macro: - **instructions.rs** - Main macro logic, generates compress/decompress handlers - **crate_context.rs** - Scans crate for `#[derive(Accounts)]` structs diff --git a/sdk-libs/macros/src/light_pdas/account/decompress_context.rs b/sdk-libs/macros/src/light_pdas/account/decompress_context.rs index 269221f96f..3a1d1f3590 100644 --- a/sdk-libs/macros/src/light_pdas/account/decompress_context.rs +++ b/sdk-libs/macros/src/light_pdas/account/decompress_context.rs @@ -4,115 +4,10 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{Ident, Result}; -// Re-export from variant_enum for convenience -pub use crate::light_pdas::program::variant_enum::PdaCtxSeedInfo; -use crate::light_pdas::shared_utils::{ - make_packed_type, make_packed_variant_name, qualify_type_with_crate, -}; - pub fn generate_decompress_context_trait_impl( - pda_ctx_seeds: Vec, token_variant_ident: Ident, lifetime: syn::Lifetime, ) -> Result { - // Generate match arms that extract idx fields, resolve Pubkeys, construct CtxSeeds - let pda_match_arms: Vec<_> = pda_ctx_seeds - .iter() - .map(|info| { - // 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; - let params_only_fields = &info.params_only_seed_fields; - // Generate pattern to extract idx fields from packed variant - let idx_field_patterns: Vec<_> = ctx_fields.iter().map(|field| { - let idx_field = format_ident!("{}_idx", field); - quote! { #idx_field } - }).collect(); - // Generate pattern to extract params-only fields from packed variant - let params_field_patterns: Vec<_> = params_only_fields.iter().map(|(field, _, _)| { - quote! { #field } - }).collect(); - // Generate code to resolve idx fields to Pubkeys - let resolve_ctx_seeds: Vec<_> = ctx_fields.iter().map(|field| { - let idx_field = format_ident!("{}_idx", field); - quote! { - let #field = *post_system_accounts - .get(#idx_field as usize) - .ok_or(solana_program_error::ProgramError::InvalidAccountData)? - .key; - } - }).collect(); - // Generate CtxSeeds struct construction - let ctx_seeds_construction = if ctx_fields.is_empty() { - quote! { let ctx_seeds = #ctx_seeds_struct_name; } - } else { - let field_inits: Vec<_> = ctx_fields.iter().map(|field| { - quote! { #field } - }).collect(); - quote! { let ctx_seeds = #ctx_seeds_struct_name { #(#field_inits),* }; } - }; - // Generate SeedParams update with params-only field values - // Note: variant_seed_params is declared OUTSIDE the match to avoid borrow checker issues - // (the reference passed to handle_packed_pda_variant would outlive the match arm scope) - // params-only fields are stored directly in packed variant (not by reference), - // so we use the value directly without dereferencing - let seed_params_update = if params_only_fields.is_empty() { - // No update needed - use the default value declared before match - quote! {} - } else { - let field_inits: Vec<_> = params_only_fields.iter().map(|(field, _, _)| { - quote! { #field: std::option::Option::Some(#field) } - }).collect(); - quote! { variant_seed_params = SeedParams { #(#field_inits,)* ..Default::default() }; } - }; - quote! { - LightAccountVariant::#packed_variant_name { data: packed, #(#idx_field_patterns,)* #(#params_field_patterns,)* .. } => { - #(#resolve_ctx_seeds)* - #ctx_seeds_construction - #seed_params_update - light_sdk::interface::handle_packed_pda_variant::<#inner_type, #packed_inner_type, _, _>( - &*self.rent_sponsor, - cpi_accounts, - address_space, - &solana_accounts[i], - i, - &packed, - &meta, - post_system_accounts, - &mut compressed_pda_infos, - &program_id, - &ctx_seeds, - std::option::Option::Some(&variant_seed_params), - )?; - } - LightAccountVariant::#variant_name { .. } => { - return std::result::Result::Err(light_sdk::error::LightSdkError::UnexpectedUnpackedVariant.into()); - } - } - }) - .collect(); - - // For mint-only programs (no PDA variants), add an arm for the Empty variant - let empty_variant_arm = if pda_ctx_seeds.is_empty() { - quote! { - // Mint-only programs have an Empty variant that should never be decompressed - LightAccountVariant::Empty => { - return std::result::Result::Err(solana_program_error::ProgramError::InvalidAccountData); - } - } - } else { - quote! {} - }; - let packed_token_variant_ident = format_ident!("Packed{}", token_variant_ident); Ok(quote! { @@ -120,7 +15,6 @@ pub fn generate_decompress_context_trait_impl( type CompressedData = LightAccountData; type PackedTokenData = light_token::compat::PackedCTokenData<#packed_token_variant_ident>; type CompressedMeta = light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; - type SeedParams = SeedParams; fn fee_payer(&self) -> &solana_account_info::AccountInfo<#lifetime> { &*self.fee_payer @@ -156,44 +50,59 @@ pub fn generate_decompress_context_trait_impl( address_space: solana_pubkey::Pubkey, compressed_accounts: Vec, solana_accounts: &[solana_account_info::AccountInfo<#lifetime>], - seed_params: std::option::Option<&Self::SeedParams>, + rent: &solana_program::sysvar::rent::Rent, + current_slot: u64, ) -> std::result::Result<( Vec<::light_sdk::compressed_account::CompressedAccountInfo>, Vec<(Self::PackedTokenData, Self::CompressedMeta)>, ), solana_program_error::ProgramError> { - solana_msg::msg!("collect_pda_and_token: start, {} accounts", compressed_accounts.len()); + use light_sdk::interface::DecompressibleAccount; + let post_system_offset = cpi_accounts.system_accounts_end_offset(); let all_infos = cpi_accounts.account_infos(); - let post_system_accounts = &all_infos[post_system_offset..]; + let remaining_accounts = &all_infos[post_system_offset..]; let program_id = &crate::ID; - solana_msg::msg!("collect_pda_and_token: allocating vecs"); let mut compressed_pda_infos = Vec::with_capacity(compressed_accounts.len()); let mut compressed_token_accounts = Vec::with_capacity(compressed_accounts.len()); - solana_msg::msg!("collect_pda_and_token: starting loop"); for (i, compressed_data) in compressed_accounts.into_iter().enumerate() { - solana_msg::msg!("collect_pda_and_token: processing account {}", i); let meta = compressed_data.meta; - // Declare variant_seed_params OUTSIDE the match to avoid borrow checker issues - // (reference passed to handle_packed_pda_variant with ? would outlive match arm scope) - let mut variant_seed_params = SeedParams::default(); - match compressed_data.data { - #(#pda_match_arms)* - LightAccountVariant::PackedCTokenData(mut data) => { - solana_msg::msg!("collect_pda_and_token: token variant {}", i); - data.token_data.version = 3; - compressed_token_accounts.push((data, meta)); - solana_msg::msg!("collect_pda_and_token: token {} done", i); + + if compressed_data.data.is_token() { + match compressed_data.data { + LightAccountVariant::PackedCTokenData(mut data) => { + data.token_data.version = 3; + compressed_token_accounts.push((data, meta)); + } + LightAccountVariant::CTokenData(_) => { + return std::result::Result::Err( + light_sdk::error::LightSdkError::UnexpectedUnpackedVariant.into() + ); + } + _ => { + return std::result::Result::Err( + solana_program_error::ProgramError::InvalidAccountData + ); + } } - LightAccountVariant::CTokenData(_) => { - return std::result::Result::Err(light_sdk::error::LightSdkError::UnexpectedUnpackedVariant.into()); + } else { + let ctx = light_sdk::interface::DecompressCtx { + program_id, + address_space, + cpi_accounts, + remaining_accounts, + rent_sponsor: &*self.rent_sponsor, + rent, + current_slot, + }; + + if let Some(info) = compressed_data.data.prepare(&ctx, &solana_accounts[i], &meta, i)? { + compressed_pda_infos.push(info); } - #empty_variant_arm } } - solana_msg::msg!("collect_pda_and_token: loop done, pdas={} tokens={}", compressed_pda_infos.len(), compressed_token_accounts.len()); std::result::Result::Ok((compressed_pda_infos, compressed_token_accounts)) } diff --git a/sdk-libs/macros/src/light_pdas/account/light_compressible.rs b/sdk-libs/macros/src/light_pdas/account/light_compressible.rs index a98bb36d64..1487b19ef3 100644 --- a/sdk-libs/macros/src/light_pdas/account/light_compressible.rs +++ b/sdk-libs/macros/src/light_pdas/account/light_compressible.rs @@ -3,8 +3,10 @@ //! This macro is equivalent to deriving: //! - `LightHasherSha` (SHA256 hashing) //! - `LightDiscriminator` (unique discriminator) -//! - `Compressible` (HasCompressionInfo + CompressAs + Size + CompressedInitSpace) +//! - `Compressible` (CompressionInfoField + CompressAs + Size + CompressedInitSpace) //! - `CompressiblePack` (Pack + Unpack + Packed struct generation) +//! +//! Note: `HasCompressionInfo` is provided via blanket impl for types implementing `CompressionInfoField`. use proc_macro2::TokenStream; use quote::quote; @@ -21,7 +23,7 @@ use crate::{ /// This is a convenience macro that combines: /// - `LightHasherSha` - SHA256-based DataHasher and ToByteArray implementations (type 3 ShaFlat) /// - `LightDiscriminator` - Unique 8-byte discriminator for the account type -/// - `Compressible` - HasCompressionInfo, CompressAs, Size, CompressedInitSpace traits +/// - `Compressible` - CompressionInfoField (blanket impl provides HasCompressionInfo), CompressAs, Size, CompressedInitSpace traits /// - `CompressiblePack` - Pack/Unpack traits with Packed struct generation for Pubkey compression /// /// # Example @@ -150,10 +152,10 @@ mod tests { "Should have discriminator constant" ); - // Should contain Compressible output (HasCompressionInfo, CompressAs, Size) + // Should contain Compressible output (CompressionInfoField, CompressAs, Size) assert!( - output.contains("HasCompressionInfo"), - "Should implement HasCompressionInfo" + output.contains("CompressionInfoField"), + "Should implement CompressionInfoField (blanket impl provides HasCompressionInfo)" ); assert!(output.contains("CompressAs"), "Should implement CompressAs"); assert!(output.contains("Size"), "Should implement Size"); @@ -218,8 +220,8 @@ mod tests { "Should implement LightDiscriminator" ); assert!( - output.contains("HasCompressionInfo"), - "Should implement HasCompressionInfo" + output.contains("CompressionInfoField"), + "Should implement CompressionInfoField (blanket impl provides HasCompressionInfo)" ); // For structs without Pubkey fields, PackedSimpleRecord should be a type alias diff --git a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs index c3052bb4f2..4288b0d6a0 100644 --- a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs +++ b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs @@ -121,6 +121,8 @@ pub struct ExtractedSeedSpec { pub inner_type: Type, /// Classified seeds from #[account(seeds = [...])] pub seeds: Vec, + /// True if the field uses zero-copy serialization (AccountLoader) + pub is_zero_copy: bool, } /// Extracted token specification for a #[light_account(token, ...)] field @@ -172,7 +174,7 @@ pub fn extract_from_accounts_struct( }; // Check for #[light_account(...)] attribute and determine its type - let (has_light_account_pda, has_light_account_mint, has_light_account_ata) = + let (has_light_account_pda, has_light_account_mint, has_light_account_ata, has_zero_copy) = check_light_account_type(&field.attrs); if has_light_account_mint { @@ -211,6 +213,7 @@ pub fn extract_from_accounts_struct( variant_name, inner_type, seeds, + is_zero_copy: has_zero_copy, }); } else if let Some(token_attr) = token_attr { // Token field - derive variant name from field name if not provided @@ -289,14 +292,15 @@ pub fn extract_from_accounts_struct( } /// Check #[light_account(...)] attributes for PDA, mint, or ATA type. -/// Returns (has_pda, has_mint, has_ata) indicating which type was detected. +/// Returns (has_pda, has_mint, has_ata, has_zero_copy) indicating which type was detected. /// /// Types: /// - PDA: `#[light_account(init)]` only (no namespace prefix) /// - Mint: `#[light_account(init, mint::...)]` /// - Token: `#[light_account(init, token::...)]` or `#[light_account(token::...)]` /// - ATA: `#[light_account(init, associated_token::...)]` or `#[light_account(associated_token::...)]` -fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool, bool) { +/// - Zero-copy: `#[light_account(init, zero_copy)]` - only valid with PDA +fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool, bool, bool) { for attr in attrs { if attr.path().is_ident("light_account") { // Parse the content to determine if it's init-only (PDA) or init+mint (Mint) @@ -329,25 +333,30 @@ fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool, bool) { .iter() .any(|t| matches!(t, proc_macro2::TokenTree::Ident(ident) if ident == "init")); + // Check for zero_copy keyword + let has_zero_copy = token_vec + .iter() + .any(|t| matches!(t, proc_macro2::TokenTree::Ident(ident) if ident == "zero_copy")); + if has_init { // If has mint namespace, it's a mint field if has_mint_namespace { - return (false, true, false); + return (false, true, false, false); } // If has associated_token namespace, it's an ATA field if has_ata_namespace { - return (false, false, true); + return (false, false, true, false); } // If has token namespace, it's NOT a PDA (handled separately) if has_token_namespace { - return (false, false, false); + return (false, false, false, false); } // Otherwise it's a plain PDA init - return (true, false, false); + return (true, false, false, has_zero_copy); } } } - (false, false, false) + (false, false, false, false) } /// Parsed #[light_account(token, ...)] or #[light_account(associated_token, ...)] attribute @@ -1191,10 +1200,11 @@ mod tests { mint::decimals = 6 )] )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + let (has_pda, has_mint, has_ata, has_zero_copy) = check_light_account_type(&attrs); assert!(!has_pda, "Should NOT be detected as PDA"); assert!(has_mint, "Should be detected as mint"); assert!(!has_ata, "Should NOT be detected as ATA"); + assert!(!has_zero_copy, "Should NOT be detected as zero_copy"); } #[test] @@ -1203,10 +1213,11 @@ mod tests { let attrs: Vec = vec![parse_quote!( #[light_account(init)] )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + let (has_pda, has_mint, has_ata, has_zero_copy) = check_light_account_type(&attrs); assert!(has_pda, "Should be detected as PDA"); assert!(!has_mint, "Should NOT be detected as mint"); assert!(!has_ata, "Should NOT be detected as ATA"); + assert!(!has_zero_copy, "Should NOT be detected as zero_copy"); } #[test] @@ -1215,10 +1226,11 @@ mod tests { let attrs: Vec = vec![parse_quote!( #[light_account(token::authority = [b"auth"])] )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + let (has_pda, has_mint, has_ata, has_zero_copy) = check_light_account_type(&attrs); assert!(!has_pda, "Should NOT be detected as PDA (no init)"); assert!(!has_mint, "Should NOT be detected as mint"); assert!(!has_ata, "Should NOT be detected as ATA"); + assert!(!has_zero_copy, "Should NOT be detected as zero_copy"); } #[test] @@ -1230,10 +1242,11 @@ mod tests { associated_token::mint = mint )] )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + let (has_pda, has_mint, has_ata, has_zero_copy) = check_light_account_type(&attrs); assert!(!has_pda, "Should NOT be detected as PDA"); assert!(!has_mint, "Should NOT be detected as mint"); assert!(has_ata, "Should be detected as ATA"); + assert!(!has_zero_copy, "Should NOT be detected as zero_copy"); } #[test] @@ -1245,9 +1258,23 @@ mod tests { token::mint = mint )] )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + let (has_pda, has_mint, has_ata, has_zero_copy) = check_light_account_type(&attrs); assert!(!has_pda, "Should NOT be detected as PDA"); assert!(!has_mint, "Should NOT be detected as mint"); assert!(!has_ata, "Should NOT be detected as ATA"); + assert!(!has_zero_copy, "Should NOT be detected as zero_copy"); + } + + #[test] + fn test_check_light_account_type_pda_zero_copy() { + // Test that zero_copy with init is detected correctly + let attrs: Vec = vec![parse_quote!( + #[light_account(init, zero_copy)] + )]; + let (has_pda, has_mint, has_ata, has_zero_copy) = check_light_account_type(&attrs); + assert!(has_pda, "Should be detected as PDA"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); + assert!(has_zero_copy, "Should be detected as zero_copy"); } } diff --git a/sdk-libs/macros/src/light_pdas/account/traits.rs b/sdk-libs/macros/src/light_pdas/account/traits.rs index ff7b01653f..11ff2f1f38 100644 --- a/sdk-libs/macros/src/light_pdas/account/traits.rs +++ b/sdk-libs/macros/src/light_pdas/account/traits.rs @@ -43,48 +43,56 @@ impl FromMeta for CompressAsFields { } } -/// Validates that the struct has a `compression_info` field +/// Validates that the struct has a `compression_info` field as first or last field. +/// Returns `Ok(true)` if first, `Ok(false)` if last, `Err` if missing or in middle. fn validate_compression_info_field( fields: &Punctuated, struct_name: &Ident, -) -> Result<()> { - let has_compression_info_field = fields.iter().any(|field| { - field - .ident - .as_ref() - .is_some_and(|name| name == "compression_info") - }); - - if !has_compression_info_field { +) -> Result { + let field_count = fields.len(); + if field_count == 0 { return Err(syn::Error::new_spanned( struct_name, - "Struct must have a 'compression_info' field of type Option", + "Struct must have at least one field", )); } - Ok(()) + let first_is_compression_info = fields + .first() + .and_then(|f| f.ident.as_ref()) + .is_some_and(|name| name == "compression_info"); + + let last_is_compression_info = fields + .last() + .and_then(|f| f.ident.as_ref()) + .is_some_and(|name| name == "compression_info"); + + if first_is_compression_info { + Ok(true) + } else if last_is_compression_info { + Ok(false) + } else { + Err(syn::Error::new_spanned( + struct_name, + "Field 'compression_info: Option' must be the first or last field in the struct \ + for efficient serialization. Move it to the beginning or end of your struct definition.", + )) + } } -/// Generates the HasCompressionInfo trait implementation -fn generate_has_compression_info_impl(struct_name: &Ident) -> TokenStream { +/// Generates the CompressionInfoField trait implementation. +/// HasCompressionInfo is provided via blanket impl in light-sdk. +fn generate_has_compression_info_impl(struct_name: &Ident, compression_info_first: bool) -> TokenStream { quote! { - impl light_sdk::interface::HasCompressionInfo for #struct_name { - fn compression_info(&self) -> std::result::Result<&light_sdk::interface::CompressionInfo, solana_program_error::ProgramError> { - self.compression_info.as_ref().ok_or(light_sdk::error::LightSdkError::MissingCompressionInfo.into()) - } + impl light_sdk::interface::CompressionInfoField for #struct_name { + const COMPRESSION_INFO_FIRST: bool = #compression_info_first; - fn compression_info_mut(&mut self) -> std::result::Result<&mut light_sdk::interface::CompressionInfo, solana_program_error::ProgramError> { - self.compression_info.as_mut().ok_or(light_sdk::error::LightSdkError::MissingCompressionInfo.into()) + fn compression_info_field(&self) -> &Option { + &self.compression_info } - - fn compression_info_mut_opt(&mut self) -> &mut Option { + fn compression_info_field_mut(&mut self) -> &mut Option { &mut self.compression_info } - - fn set_compression_info_none(&mut self) -> std::result::Result<(), solana_program_error::ProgramError> { - self.compression_info = None; - Ok(()) - } } } } @@ -155,43 +163,20 @@ fn generate_compress_as_impl( } } -/// Generates size calculation fields for the Size trait. -/// Auto-skips `compression_info` field and fields marked with `#[skip]`. -fn generate_size_fields(fields: &Punctuated) -> Vec { - let mut size_fields = Vec::new(); - - for field in fields.iter() { - let Some(field_name) = field.ident.as_ref() else { - continue; - }; - - // Auto-skip compression_info field (handled separately in Size impl) - if field_name == "compression_info" { - continue; - } - - // Also skip fields explicitly marked with #[skip] - if field.attrs.iter().any(|attr| attr.path().is_ident("skip")) { - continue; - } - - size_fields.push(quote! { - + self.#field_name.try_to_vec().expect("Failed to serialize").len() - }); - } - - size_fields -} - -/// Generates the Size trait implementation -fn generate_size_impl(struct_name: &Ident, size_fields: &[TokenStream]) -> TokenStream { +/// Generates the Size trait implementation. +/// Uses max(INIT_SPACE, serialized_len) to ensure enough space while handling edge cases. +fn generate_size_impl(struct_name: &Ident) -> TokenStream { quote! { impl light_sdk::account::Size for #struct_name { + #[inline] fn size(&self) -> std::result::Result { - // Always allocate space for Some(CompressionInfo) since it will be set during decompression - // CompressionInfo size: 1 byte (Option discriminant) + ::INIT_SPACE - let compression_info_size = 1 + ::INIT_SPACE; - Ok(compression_info_size #(#size_fields)*) + // Use Anchor's compile-time INIT_SPACE as the baseline. + // Fall back to serialized length if it's somehow larger (edge case safety). + let init_space = ::INIT_SPACE; + let serialized_len = self.try_to_vec() + .map_err(|_| solana_program_error::ProgramError::BorshIoError("serialization failed".to_string()))? + .len(); + Ok(core::cmp::max(init_space, serialized_len)) } } } @@ -231,8 +216,8 @@ pub fn derive_has_compression_info(input: syn::ItemStruct) -> Result Result { @@ -253,17 +238,16 @@ pub fn derive_compressible(input: DeriveInput) -> Result { None }; - // Validate compression_info field exists - validate_compression_info_field(fields, struct_name)?; + // Validate compression_info field exists and get its position + let compression_info_first = validate_compression_info_field(fields, struct_name)?; // Generate all trait implementations using helper functions - let has_compression_info_impl = generate_has_compression_info_impl(struct_name); + let has_compression_info_impl = generate_has_compression_info_impl(struct_name, compression_info_first); let field_assignments = generate_compress_as_field_assignments(fields, &compress_as_fields); let compress_as_impl = generate_compress_as_impl(struct_name, &field_assignments); - let size_fields = generate_size_fields(fields); - let size_impl = generate_size_impl(struct_name, &size_fields); + let size_impl = generate_size_impl(struct_name); let compressed_init_space_impl = generate_compressed_init_space_impl(struct_name); @@ -275,3 +259,95 @@ pub fn derive_compressible(input: DeriveInput) -> Result { #compressed_init_space_impl }) } + +/// Validates that the struct has a `compression_info` field for Pod types. +/// Unlike Borsh version, the field type is `CompressionInfo` (not `Option`). +/// Returns `Ok(())` if found, `Err` if missing. +fn validate_pod_compression_info_field( + fields: &Punctuated, + struct_name: &Ident, +) -> Result<()> { + let has_compression_info = fields + .iter() + .any(|f| f.ident.as_ref().is_some_and(|name| name == "compression_info")); + + if !has_compression_info { + return Err(syn::Error::new_spanned( + struct_name, + "Pod struct must have a 'compression_info: CompressionInfo' field (non-optional). \ + For Pod types, use `light_compressible::compression_info::CompressionInfo`.", + )); + } + Ok(()) +} + +/// Validates that the struct has `#[repr(C)]` attribute required for Pod types. +fn validate_repr_c(attrs: &[syn::Attribute], struct_name: &Ident) -> Result<()> { + let has_repr_c = attrs.iter().any(|attr| { + if !attr.path().is_ident("repr") { + return false; + } + // Parse the repr attribute to check for 'C' + if let syn::Meta::List(meta_list) = &attr.meta { + return meta_list.tokens.to_string().contains('C'); + } + false + }); + + if !has_repr_c { + return Err(syn::Error::new_spanned( + struct_name, + "Pod struct must have #[repr(C)] attribute for predictable field layout. \ + Add `#[repr(C)]` above your struct definition.", + )); + } + Ok(()) +} + +/// Generates the PodCompressionInfoField trait implementation for Pod (zero-copy) structs. +/// +/// Uses `core::mem::offset_of!()` for compile-time offset calculation. +/// This requires the struct to be `#[repr(C)]` for predictable field layout. +fn generate_pod_compression_info_impl(struct_name: &Ident) -> TokenStream { + quote! { + impl light_sdk::interface::PodCompressionInfoField for #struct_name { + const COMPRESSION_INFO_OFFSET: usize = core::mem::offset_of!(#struct_name, compression_info); + } + } +} + +/// Derives PodCompressionInfoField for a `#[repr(C)]` struct. +/// +/// Requirements: +/// 1. Struct must have `#[repr(C)]` attribute +/// 2. Struct must have `compression_info: CompressionInfo` field (non-optional) +/// 3. Struct must implement `bytemuck::Pod` and `bytemuck::Zeroable` +/// +/// # Example +/// +/// ```ignore +/// use light_sdk_macros::PodCompressionInfoField; +/// use light_compressible::compression_info::CompressionInfo; +/// use bytemuck::{Pod, Zeroable}; +/// +/// #[derive(Pod, Zeroable, PodCompressionInfoField)] +/// #[repr(C)] +/// pub struct MyPodAccount { +/// pub owner: [u8; 32], +/// pub data: u64, +/// pub compression_info: CompressionInfo, +/// } +/// ``` +pub fn derive_pod_compression_info_field(input: DeriveInput) -> Result { + let struct_name = &input.ident; + let fields = extract_fields_from_derive_input(&input)?; + + // Validate #[repr(C)] attribute + validate_repr_c(&input.attrs, struct_name)?; + + // Validate compression_info field exists + validate_pod_compression_info_field(fields, struct_name)?; + + // Generate trait implementation + Ok(generate_pod_compression_info_impl(struct_name)) +} diff --git a/sdk-libs/macros/src/light_pdas/accounts/builder.rs b/sdk-libs/macros/src/light_pdas/accounts/builder.rs index 548962587f..c5b472d4b9 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/builder.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/builder.rs @@ -388,34 +388,23 @@ impl LightAccountsBuilder { let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new_with_config( &self.#fee_payer, _remaining, - ::light_sdk::sdk_types::CpiAccountsConfig::new_with_cpi_context(crate::LIGHT_CPI_SIGNER), + light_sdk::cpi::CpiAccountsConfig::new_with_cpi_context(crate::LIGHT_CPI_SIGNER), ); - let compression_config_data = light_sdk::interface::LightConfig::load_checked( &self.#compression_config, - &crate::ID + &crate::ID, )?; let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); #(#compress_blocks)* - let cpi_context_account = cpi_accounts.cpi_context()?; - let cpi_context_accounts = ::light_sdk::sdk_types::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( + light_token::compressible::invoke_write_pdas_to_cpi_context( crate::LIGHT_CPI_SIGNER, - #proof_access.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)?; + #proof_access.proof.clone(), + &[#(#new_addr_idents),*], + &all_compressed_infos, + &cpi_accounts, + )?; #mint_invocation }) @@ -434,24 +423,24 @@ impl LightAccountsBuilder { let compression_config = &self.infra.compression_config; Ok(quote! { + use light_sdk::cpi::{LightCpiInstruction, InvokeLightSystemProgram}; + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( &self.#fee_payer, _remaining, crate::LIGHT_CPI_SIGNER, ); - let compression_config_data = light_sdk::interface::LightConfig::load_checked( &self.#compression_config, - &crate::ID + &crate::ID, )?; let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); #(#compress_blocks)* - use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( crate::LIGHT_CPI_SIGNER, - #proof_access.proof.clone() + #proof_access.proof.clone(), ) .with_new_addresses(&[#(#new_addr_idents),*]) .with_account_infos(&all_compressed_infos) diff --git a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs index da291c6fb9..5cdf831d26 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs @@ -103,6 +103,8 @@ pub struct PdaField { pub output_tree: Expr, /// True if the field is Box>, false if Account pub is_boxed: bool, + /// True if the field uses zero-copy serialization (AccountLoader) + pub is_zero_copy: bool, } /// A field marked with #[light_account([init,] token, ...)] (Token Account). @@ -206,6 +208,8 @@ struct LightAccountArgs { has_init: bool, /// True if `token` keyword is present (marks token fields - skip in LightAccounts derive). is_token: bool, + /// True if `zero_copy` keyword is present (for AccountLoader fields using Pod serialization). + has_zero_copy: bool, /// The account type (Pda, Mint, etc.). account_type: LightAccountType, /// Namespaced key-value pairs for additional arguments. @@ -272,6 +276,7 @@ impl Parse for LightAccountArgs { return Ok(Self { has_init: false, is_token: true, // Skip in LightAccounts derive (for mark-only mode) + has_zero_copy: false, account_type, key_values, }); @@ -288,6 +293,7 @@ impl Parse for LightAccountArgs { return Ok(Self { has_init: false, is_token: true, + has_zero_copy: false, account_type, key_values, }); @@ -302,6 +308,7 @@ impl Parse for LightAccountArgs { let mut account_type = LightAccountType::Pda; let mut key_values = Vec::new(); + let mut has_zero_copy = false; // Parse remaining tokens while !input.is_empty() { @@ -316,6 +323,13 @@ impl Parse for LightAccountArgs { let lookahead = input.fork(); let ident: Ident = lookahead.parse()?; + // Check for zero_copy keyword (standalone flag) + if ident == "zero_copy" { + input.parse::()?; // consume it + has_zero_copy = true; + continue; + } + // If followed by `::`, infer type from namespace if lookahead.peek(Token![::]) { // Infer account type from namespace @@ -368,6 +382,7 @@ impl Parse for LightAccountArgs { Ok(Self { has_init: true, is_token: false, + has_zero_copy, account_type, key_values, }) @@ -547,7 +562,7 @@ pub(super) fn parse_light_account_attr( return match args.account_type { LightAccountType::Pda => Ok(Some(LightAccountField::Pda(Box::new( - build_pda_field(field, field_ident, &args.key_values, direct_proof_arg)?, + build_pda_field(field, field_ident, &args.key_values, direct_proof_arg, args.has_zero_copy)?, )))), LightAccountType::Mint => Ok(Some(LightAccountField::Mint(Box::new( build_mint_field(field_ident, &args.key_values, attr, direct_proof_arg)?, @@ -566,15 +581,29 @@ pub(super) fn parse_light_account_attr( Ok(None) } +/// Check if a type is AccountLoader<'info, T> +fn is_account_loader_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + return type_path + .path + .segments + .iter() + .any(|seg| seg.ident == "AccountLoader"); + } + false +} + /// Build a PdaField from parsed key-value pairs. /// /// # Arguments /// * `direct_proof_arg` - If `Some`, use `.field` for defaults instead of `params.create_accounts_proof.field` +/// * `has_zero_copy` - True if `zero_copy` keyword was present in the attribute fn build_pda_field( field: &Field, field_ident: &Ident, key_values: &[NamespacedKeyValue], direct_proof_arg: &Option, + has_zero_copy: bool, ) -> Result { // Reject any key-value pairs - PDA only needs `init` // Tree info is always auto-fetched from CreateAccountsProof @@ -607,11 +636,33 @@ fn build_pda_field( ) }; - // Validate this is an Account type (or Box) + // Detect if field type is AccountLoader + let is_account_loader = is_account_loader_type(&field.ty); + + // Validate AccountLoader requires zero_copy + if is_account_loader && !has_zero_copy { + return Err(Error::new_spanned( + &field.ty, + "AccountLoader fields require #[light_account(init, zero_copy)]. \ + AccountLoader uses zero-copy (Pod) serialization which is incompatible \ + with the default Borsh decompression path.", + )); + } + + // Validate non-AccountLoader forbids zero_copy + if !is_account_loader && has_zero_copy { + return Err(Error::new_spanned( + &field.ty, + "zero_copy can only be used with AccountLoader fields. \ + For Account<'info, T> fields, remove the zero_copy keyword.", + )); + } + + // Validate this is an Account type (or Box) or AccountLoader let (is_boxed, inner_type) = extract_account_inner_type(&field.ty).ok_or_else(|| { Error::new_spanned( &field.ty, - "#[light_account(init)] can only be applied to Account<...> or Box> fields. \ + "#[light_account(init)] can only be applied to Account<...>, Box>, or AccountLoader<...> fields. \ Nested Box> is not supported.", ) })?; @@ -622,6 +673,7 @@ fn build_pda_field( address_tree_info, output_tree, is_boxed, + is_zero_copy: has_zero_copy, }) } @@ -1003,6 +1055,7 @@ impl From for super::parse::ParsedPdaField { address_tree_info: pda.address_tree_info, output_tree: pda.output_tree, is_boxed: pda.is_boxed, + is_zero_copy: pda.is_zero_copy, } } } diff --git a/sdk-libs/macros/src/light_pdas/accounts/mint.rs b/sdk-libs/macros/src/light_pdas/accounts/mint.rs index 1e75421e44..074b6c87c2 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/mint.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/mint.rs @@ -408,57 +408,38 @@ fn generate_mints_invocation(builder: &LightMintsBuilder) -> TokenStream { #(#mint_account_exprs),* ]; - // Get tree accounts and indices - // Output queue for state (compressed accounts) uses output_state_tree_index from proof - // State merkle tree index comes from the proof (set by pack_proof_for_mints) - // Address merkle tree index comes from the proof's address_tree_info + // Get tree indices from proof let __tree_info = &#proof_access.address_tree_info; let __output_queue_index: u8 = #output_tree; let __state_tree_index: u8 = #proof_access.state_tree_index .ok_or(anchor_lang::prelude::ProgramError::InvalidArgument)?; let __address_tree_index: u8 = __tree_info.address_merkle_tree_pubkey_index; - let __output_queue = cpi_accounts.get_tree_account_info(__output_queue_index as usize)?; - let __state_merkle_tree = cpi_accounts.get_tree_account_info(__state_tree_index as usize)?; - let __address_tree = cpi_accounts.get_tree_account_info(__address_tree_index as usize)?; - - // Build CreateMintsParams with tree indices - let __create_mints_params = light_token::instruction::CreateMintsParams::new( - &__mint_params, - __proof, - ) - .with_rent_payment(#rent_payment) - .with_write_top_up(#write_top_up) // TODO: discuss to allow a different one per mint. - .with_cpi_context_offset(#cpi_context_offset) - .with_output_queue_index(__output_queue_index) - .with_address_tree_index(__address_tree_index) - .with_state_tree_index(__state_tree_index); // Check authority signers for mints without authority_seeds #(#authority_signer_checks)* - // Build and invoke CreateMintsCpi - // Seeds are extracted from SingleMintParams internally - light_token::instruction::CreateMintsCpi { - mint_seed_accounts: &__mint_seed_accounts, - payer: self.#fee_payer.to_account_info(), - address_tree: __address_tree.clone(), - output_queue: __output_queue.clone(), - state_merkle_tree: __state_merkle_tree.clone(), - compressible_config: self.#light_token_config.to_account_info(), - mints: &__mint_accounts, - rent_sponsor: self.#light_token_rent_sponsor.to_account_info(), - system_accounts: light_token::instruction::SystemAccountInfos { - light_system_program: cpi_accounts.light_system_program()?.clone(), - cpi_authority_pda: self.#light_token_cpi_authority.to_account_info(), - registered_program_pda: cpi_accounts.registered_program_pda()?.clone(), - account_compression_authority: cpi_accounts.account_compression_authority()?.clone(), - account_compression_program: cpi_accounts.account_compression_program()?.clone(), - system_program: cpi_accounts.system_program()?.clone(), + // Build params and invoke CreateMintsCpi via helper + light_token::compressible::invoke_create_mints( + &__mint_seed_accounts, + &__mint_accounts, + light_token::instruction::CreateMintsParams { + mints: &__mint_params, + proof: __proof, + rent_payment: #rent_payment, + write_top_up: #write_top_up, + cpi_context_offset: #cpi_context_offset, + output_queue_index: __output_queue_index, + address_tree_index: __address_tree_index, + state_tree_index: __state_tree_index, }, - cpi_context_account: cpi_accounts.cpi_context()?.clone(), - params: __create_mints_params, - } - .invoke()?; + light_token::compressible::CreateMintsInfraAccounts { + fee_payer: self.#fee_payer.to_account_info(), + compressible_config: self.#light_token_config.to_account_info(), + rent_sponsor: self.#light_token_rent_sponsor.to_account_info(), + cpi_authority: self.#light_token_cpi_authority.to_account_info(), + }, + &cpi_accounts, + )?; } } } diff --git a/sdk-libs/macros/src/light_pdas/accounts/parse.rs b/sdk-libs/macros/src/light_pdas/accounts/parse.rs index 8f30e347a5..8797747b89 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/parse.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/parse.rs @@ -170,6 +170,7 @@ pub(super) struct ParsedLightAccountsStruct { } /// A field marked with #[light_account(init)] +#[allow(dead_code)] // is_zero_copy is read via From conversion in program module pub(super) struct ParsedPdaField { pub ident: Ident, /// The inner type T from Account<'info, T> or Box> @@ -179,6 +180,8 @@ pub(super) struct ParsedPdaField { pub output_tree: Expr, /// True if the field is Box>, false if Account pub is_boxed: bool, + /// True if the field uses zero-copy serialization (AccountLoader) + pub is_zero_copy: bool, } /// Instruction argument from #[instruction(...)] diff --git a/sdk-libs/macros/src/light_pdas/accounts/pda.rs b/sdk-libs/macros/src/light_pdas/accounts/pda.rs index 884aa073c2..db895ada37 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/pda.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/pda.rs @@ -112,19 +112,29 @@ impl<'a> PdaBlockBuilder<'a> { } } - /// Generate mutable reference to account data (handles Box vs Account). + /// Generate mutable reference to account data (handles Box, Account, AccountLoader). fn account_data_extraction(&self) -> TokenStream { let ident = &self.field.ident; let account_data = &self.idents.account_data; - let deref_expr = if self.field.is_boxed { - quote! { &mut **self.#ident } + if self.field.is_zero_copy { + // AccountLoader uses load_init() for newly initialized accounts + // Must keep guard alive while accessing data + // Convert anchor_lang::error::Error to ProgramError using .into() + let account_guard = format_ident!("{}_guard", ident); + quote! { + let mut #account_guard = self.#ident.load_init() + .map_err(|_| solana_program_error::ProgramError::InvalidAccountData)?; + let #account_data = &mut *#account_guard; + } + } else if self.field.is_boxed { + quote! { + let #account_data = &mut **self.#ident; + } } else { - quote! { &mut *self.#ident } - }; - - quote! { - let #account_data = #deref_expr; + quote! { + let #account_data = &mut *self.#ident; + } } } @@ -138,18 +148,39 @@ impl<'a> PdaBlockBuilder<'a> { let new_addr_params = &self.idents.new_addr_params; let compressed_infos = &self.idents.compressed_infos; + // Use pod variant for zero_copy accounts (AccountLoader with Pod types) + let prepare_call = if self.field.is_zero_copy { + quote! { + light_sdk::interface::prepare_compressed_account_on_init_pod::<#inner_type>( + &#account_info, + #account_data, + &compression_config_data, + #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. + )? + } + } else { + quote! { + light_sdk::interface::prepare_compressed_account_on_init::<#inner_type>( + &#account_info, + #account_data, + &compression_config_data, + #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. + )? + } + }; + quote! { - let #compressed_infos = light_sdk::interface::prepare_compressed_account_on_init::<#inner_type>( - &#account_info, - #account_data, - &compression_config_data, - #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. - )?; + let #compressed_infos = #prepare_call; all_compressed_infos.push(#compressed_infos); } } diff --git a/sdk-libs/macros/src/light_pdas/light_account_keywords.rs b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs index 59185b9af1..cf03acb1b8 100644 --- a/sdk-libs/macros/src/light_pdas/light_account_keywords.rs +++ b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs @@ -51,7 +51,7 @@ pub const MINT_NAMESPACE_KEYS: &[&str] = &[ /// Standalone keywords that don't require a value (flags). /// These can appear as bare identifiers without `= value`. -pub const STANDALONE_KEYWORDS: &[&str] = &["init", "token", "associated_token", "mint"]; +pub const STANDALONE_KEYWORDS: &[&str] = &["init", "token", "associated_token", "mint", "zero_copy"]; /// Keywords that support shorthand syntax within their namespace. /// For example, `token::mint` alone is equivalent to `token::mint = mint`. @@ -212,6 +212,7 @@ mod tests { assert!(is_standalone_keyword("token")); assert!(is_standalone_keyword("associated_token")); assert!(is_standalone_keyword("mint")); + assert!(is_standalone_keyword("zero_copy")); assert!(!is_standalone_keyword("authority")); } diff --git a/sdk-libs/macros/src/light_pdas/mod.rs b/sdk-libs/macros/src/light_pdas/mod.rs index 907660739a..cc125ab53c 100644 --- a/sdk-libs/macros/src/light_pdas/mod.rs +++ b/sdk-libs/macros/src/light_pdas/mod.rs @@ -1,7 +1,7 @@ //! Rent-free account compression macros. //! //! This module organizes all rent-free related macros: -//! - `program/` - `#[rentfree_program]` attribute macro for program-level auto-discovery +//! - `program/` - `#[light_program]` attribute macro for program-level auto-discovery //! - `accounts/` - `#[derive(LightAccounts)]` derive macro for Accounts structs //! - `account/` - Trait derive macros for account data structs (Compressible, Pack, HasCompressionInfo, etc.) //! - `light_account_keywords` - Shared keyword definitions for `#[light_account(...)]` parsing diff --git a/sdk-libs/macros/src/light_pdas/program/compress.rs b/sdk-libs/macros/src/light_pdas/program/compress.rs index b3723f6622..b7dda0c92d 100644 --- a/sdk-libs/macros/src/light_pdas/program/compress.rs +++ b/sdk-libs/macros/src/light_pdas/program/compress.rs @@ -14,32 +14,38 @@ use crate::light_pdas::shared_utils::qualify_type_with_crate; // COMPRESS BUILDER // ============================================================================= +/// Information about a compressible account type. +#[derive(Clone)] +pub struct CompressibleAccountInfo { + /// The account type. + pub account_type: Type, + /// True if the account uses zero-copy (Pod) serialization. + pub is_zero_copy: bool, +} + /// Builder for generating compress instruction code. /// /// Encapsulates the account types and variant configuration needed to generate /// all compress-related code: context implementation, processor function, /// instruction entrypoint, and accounts struct. pub(super) struct CompressBuilder { - /// Account types that can be compressed. - account_types: Vec, + /// Account types that can be compressed with their zero_copy flags. + accounts: Vec, /// The instruction variant (PdaOnly, TokenOnly, or Mixed). variant: InstructionVariant, } impl CompressBuilder { - /// Create a new CompressBuilder with the given account types and variant. + /// Create a new CompressBuilder with the given account infos and variant. /// /// # Arguments - /// * `account_types` - The account types that can be compressed + /// * `accounts` - The account types with their zero_copy flags /// * `variant` - The instruction variant determining what gets generated /// /// # Returns /// A new CompressBuilder instance - pub fn new(account_types: Vec, variant: InstructionVariant) -> Self { - Self { - account_types, - variant, - } + pub fn new(accounts: Vec, variant: InstructionVariant) -> Self { + Self { accounts, variant } } // ------------------------------------------------------------------------- @@ -66,7 +72,7 @@ impl CompressBuilder { /// `Ok(())` if validation passes, or a `syn::Error` describing the issue. pub fn validate(&self) -> Result<()> { // For variants that include PDAs, require at least one account type - if self.has_pdas() && self.account_types.is_empty() { + if self.has_pdas() && self.accounts.is_empty() { return Err(syn::Error::new( proc_macro2::Span::call_site(), "CompressBuilder requires at least one account type for PDA compression", @@ -86,25 +92,51 @@ impl CompressBuilder { pub fn generate_context_impl(&self) -> Result { let lifetime: syn::Lifetime = syn::parse_quote!('info); - let compress_arms: Vec<_> = self.account_types.iter().map(|account_type| { - let name = qualify_type_with_crate(account_type); - quote! { - d if d == #name::LIGHT_DISCRIMINATOR => { - drop(data); - let data_borrow = account_info.try_borrow_data().map_err(__anchor_to_program_error)?; - let mut account_data = #name::try_deserialize(&mut &data_borrow[..]) - .map_err(__anchor_to_program_error)?; - drop(data_borrow); + let compress_arms: Vec<_> = self.accounts.iter().map(|info| { + let name = qualify_type_with_crate(&info.account_type); + + if info.is_zero_copy { + // Pod (zero-copy) path: use bytemuck instead of Borsh + quote! { + d if d == #name::LIGHT_DISCRIMINATOR => { + drop(data); + let data_borrow = account_info.try_borrow_data().map_err(__anchor_to_program_error)?; + // Skip 8-byte discriminator and read Pod data directly + let pod_bytes = &data_borrow[8..8 + core::mem::size_of::<#name>()]; + let mut account_data: #name = *bytemuck::from_bytes(pod_bytes); + drop(data_borrow); - let compressed_info = light_sdk::interface::compress_account::prepare_account_for_compression::<#name>( - program_id, - account_info, - &mut account_data, - meta, - cpi_accounts, - &compression_config.address_space, - )?; - Ok(Some(compressed_info)) + let compressed_info = light_sdk::interface::compress_account::prepare_account_for_compression_pod::<#name>( + program_id, + account_info, + &mut account_data, + meta, + cpi_accounts, + &compression_config.address_space, + )?; + Ok(Some(compressed_info)) + } + } + } else { + // Borsh path: use anchor deserialization + quote! { + d if d == #name::LIGHT_DISCRIMINATOR => { + drop(data); + let data_borrow = account_info.try_borrow_data().map_err(__anchor_to_program_error)?; + let mut account_data = #name::try_deserialize(&mut &data_borrow[..]) + .map_err(__anchor_to_program_error)?; + drop(data_borrow); + + let compressed_info = light_sdk::interface::compress_account::prepare_account_for_compression::<#name>( + program_id, + account_info, + &mut account_data, + meta, + cpi_accounts, + &compression_config.address_space, + )?; + Ok(Some(compressed_info)) + } } } }).collect(); @@ -236,17 +268,33 @@ impl CompressBuilder { /// Generate compile-time size validation for compressed accounts. pub fn generate_size_validation(&self) -> Result { - let size_checks: Vec<_> = self.account_types.iter().map(|account_type| { - let qualified_type = qualify_type_with_crate(account_type); - quote! { - const _: () = { - const COMPRESSED_SIZE: usize = 8 + <#qualified_type as light_sdk::interface::compression_info::CompressedInitSpace>::COMPRESSED_INIT_SPACE; - if COMPRESSED_SIZE > 800 { - panic!(concat!( - "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" - )); - } - }; + let size_checks: Vec<_> = self.accounts.iter().map(|info| { + let qualified_type = qualify_type_with_crate(&info.account_type); + + if info.is_zero_copy { + // For Pod types, use core::mem::size_of for size calculation + quote! { + const _: () = { + const COMPRESSED_SIZE: usize = 8 + core::mem::size_of::<#qualified_type>(); + if COMPRESSED_SIZE > 800 { + panic!(concat!( + "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" + )); + } + }; + } + } else { + // For Borsh types, use CompressedInitSpace trait + quote! { + const _: () = { + const COMPRESSED_SIZE: usize = 8 + <#qualified_type as light_sdk::interface::compression_info::CompressedInitSpace>::COMPRESSED_INIT_SPACE; + if COMPRESSED_SIZE > 800 { + panic!(concat!( + "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" + )); + } + }; + } } }).collect(); diff --git a/sdk-libs/macros/src/light_pdas/program/crate_context.rs b/sdk-libs/macros/src/light_pdas/program/crate_context.rs index d01572d0dc..a0d77d9847 100644 --- a/sdk-libs/macros/src/light_pdas/program/crate_context.rs +++ b/sdk-libs/macros/src/light_pdas/program/crate_context.rs @@ -1,7 +1,7 @@ -//! Anchor-style crate context parser for `#[rentfree_program]`. +//! Anchor-style crate context parser for `#[light_program]`. //! //! This module recursively reads all module files at macro expansion time, -//! allowing `#[rentfree_program]` to discover all `#[derive(LightAccounts)]` structs +//! allowing `#[light_program]` to discover all `#[derive(LightAccounts)]` structs //! across the entire crate. //! //! Based on Anchor's `CrateContext::parse()` pattern from `anchor-syn/src/parser/context.rs`. diff --git a/sdk-libs/macros/src/light_pdas/program/decompress.rs b/sdk-libs/macros/src/light_pdas/program/decompress.rs index 95366f1e7c..f2687929d8 100644 --- a/sdk-libs/macros/src/light_pdas/program/decompress.rs +++ b/sdk-libs/macros/src/light_pdas/program/decompress.rs @@ -63,7 +63,6 @@ impl DecompressBuilder { let trait_impl = crate::light_pdas::account::decompress_context::generate_decompress_context_trait_impl( - self.pda_ctx_seeds.clone(), self.token_variant_ident.clone(), lifetime, )?; @@ -88,6 +87,9 @@ impl DecompressBuilder { compressed_accounts: Vec, system_accounts_offset: u8, ) -> Result<()> { + use solana_program::sysvar::Sysvar; + let rent = solana_program::sysvar::rent::Rent::get()?; + let current_slot = solana_program::sysvar::clock::Clock::get()?.slot; light_sdk::interface::process_decompress_accounts_idempotent( accounts, remaining_accounts, @@ -96,7 +98,8 @@ impl DecompressBuilder { system_accounts_offset, LIGHT_CPI_SIGNER, &crate::ID, - None, + &rent, + current_slot, ) .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) } diff --git a/sdk-libs/macros/src/light_pdas/program/instructions.rs b/sdk-libs/macros/src/light_pdas/program/instructions.rs index 79f2682c7e..feff06814a 100644 --- a/sdk-libs/macros/src/light_pdas/program/instructions.rs +++ b/sdk-libs/macros/src/light_pdas/program/instructions.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Item, ItemMod, Result, Type}; +use syn::{Item, ItemMod, Result}; // Re-export types from parsing for external use pub use super::parsing::{ @@ -10,7 +10,7 @@ pub use super::parsing::{ SeedElement, TokenSeedSpec, }; use super::{ - compress::CompressBuilder, + compress::{CompressBuilder, CompressibleAccountInfo}, decompress::DecompressBuilder, parsing::{ convert_classified_to_seed_elements, convert_classified_to_seed_elements_vec, @@ -32,7 +32,7 @@ use crate::{ #[allow(clippy::too_many_arguments)] fn codegen( module: &mut ItemMod, - account_types: Vec, + compressible_accounts: Vec, pda_seeds: Option>, token_seeds: Option>, instruction_data: Vec, @@ -102,6 +102,7 @@ fn codegen( ctx_fields, state_field_names, params_only_seed_fields, + spec.is_zero_copy, ) }) .collect() @@ -196,6 +197,34 @@ fn codegen( } } } + + impl light_sdk::interface::DecompressibleAccount for LightAccountVariant { + fn is_token(&self) -> bool { + match self { + Self::Empty => false, + Self::PackedCTokenData(_) => true, + Self::CTokenData(_) => true, + } + } + + fn prepare<'a, 'info>( + self, + _ctx: &light_sdk::interface::DecompressCtx<'a, 'info>, + _solana_account: &solana_account_info::AccountInfo<'info>, + _meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + _index: usize, + ) -> std::result::Result< + std::option::Option, + solana_program_error::ProgramError + > { + match self { + Self::Empty => Err(solana_program_error::ProgramError::InvalidAccountData), + Self::PackedCTokenData(_) | Self::CTokenData(_) => { + Err(light_sdk::error::LightSdkError::TokenPrepareCalled.into()) + } + } + } + } } } else { LightVariantBuilder::new(&pda_ctx_seeds).build()? @@ -282,22 +311,58 @@ fn codegen( }) }).collect(); // Only generate verifications for data fields that exist on the state struct + // For zero_copy accounts, convert Pubkey to bytes for comparison + let is_zero_copy = ctx_info.is_zero_copy; let data_verifications: Vec<_> = data_fields.iter().filter_map(|field| { let field_str = field.to_string(); // Skip fields that don't exist on the state struct (e.g., params-only seeds) if !ctx_info.state_field_names.contains(&field_str) { return None; } - Some(quote! { - if data.#field != seeds.#field { - return std::result::Result::Err(LightInstructionError::SeedMismatch.into()); - } - }) + if is_zero_copy { + // For zero_copy accounts, Pod types use [u8; 32] instead of Pubkey, + // so convert the seed's Pubkey to bytes for comparison + Some(quote! { + if data.#field != seeds.#field.to_bytes() { + return std::result::Result::Err(LightInstructionError::SeedMismatch.into()); + } + }) + } else { + Some(quote! { + if data.#field != seeds.#field { + return std::result::Result::Err(LightInstructionError::SeedMismatch.into()); + } + }) + } }).collect(); // Extract params-only field names from ctx_info for variant construction let params_only_field_names: Vec<_> = params_only_fields.iter().map(|(f, _, _)| f).collect(); + // Generate different code for zero_copy vs Borsh accounts + let (deserialize_code, variant_data) = if is_zero_copy { + // For zero_copy accounts, account_data contains stripped bytes (CompressionInfo removed). + // Use unpack_stripped to reconstruct full Pod for seed verification. + // Store stripped bytes in variant - packing will keep them stripped. + ( + quote! { + // Reconstruct full Pod from stripped bytes (zeros at CompressionInfo offset) + let data: #inner_type = <#inner_type as light_sdk::interface::PodCompressionInfoField>::unpack_stripped(account_data) + .map_err(|_| anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::AccountDidNotDeserialize))?; + }, + quote! { account_data.to_vec() } + ) + } else { + // For Borsh accounts, deserialize and use the data directly + ( + quote! { + use anchor_lang::AnchorDeserialize; + let data = #inner_type::deserialize(&mut &account_data[..])?; + }, + quote! { data } + ) + }; + quote! { #[derive(Clone, Debug)] pub struct #seeds_struct_name { @@ -309,16 +374,14 @@ fn codegen( account_data: &[u8], seeds: #seeds_struct_name, ) -> std::result::Result { - use anchor_lang::AnchorDeserialize; - // Deserialize using inner_type - let data = #inner_type::deserialize(&mut &account_data[..])?; + #deserialize_code #(#data_verifications)* // Use variant_name for the enum variant // Include ctx fields and params-only fields from seeds std::result::Result::Ok(Self::#variant_name { - data, + data: #variant_data, #(#ctx_fields: seeds.#ctx_fields,)* #(#params_only_field_names: seeds.#params_only_field_names,)* }) @@ -355,7 +418,7 @@ fn codegen( }; // Create CompressBuilder to generate all compress-related code - let compress_builder = CompressBuilder::new(account_types.clone(), instruction_variant); + let compress_builder = CompressBuilder::new(compressible_accounts.clone(), instruction_variant); compress_builder.validate()?; let size_validation_checks = compress_builder.generate_size_validation()?; @@ -377,7 +440,8 @@ fn codegen( impl light_sdk::interface::HasTokenVariant for LightAccountData { fn is_packed_token(&self) -> bool { - matches!(self.data, LightAccountVariant::PackedCTokenData(_)) + use light_sdk::interface::DecompressibleAccount; + self.data.is_token() } } } @@ -659,7 +723,7 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result, {})`,\n\ use: `fn {}(ctx: Context, params: MyParams)` where MyParams contains all fields.", @@ -685,7 +749,7 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result = Vec::new(); let mut found_data_fields: Vec = Vec::new(); - let mut account_types: Vec = Vec::new(); + let mut compressible_accounts: Vec = Vec::new(); let mut seen_variants: std::collections::HashSet = std::collections::HashSet::new(); for pda in &pda_specs { @@ -696,7 +760,10 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result Result Result Result, + /// True if the field uses zero-copy serialization (AccountLoader). + /// Only set for PDAs extracted from #[light_account(init, zero_copy)] fields; false by default. + pub is_zero_copy: bool, } impl Parse for TokenSeedSpec { @@ -150,7 +153,8 @@ impl Parse for TokenSeedSpec { is_token, seeds, authority, - inner_type: None, // Set by caller for #[light_account(init)] fields + inner_type: None, // Set by caller for #[light_account(init)] fields + is_zero_copy: false, // Set by caller for #[light_account(init, zero_copy)] fields }) } } diff --git a/sdk-libs/macros/src/light_pdas/program/seed_codegen.rs b/sdk-libs/macros/src/light_pdas/program/seed_codegen.rs index bb2e3c2281..8a879f961d 100644 --- a/sdk-libs/macros/src/light_pdas/program/seed_codegen.rs +++ b/sdk-libs/macros/src/light_pdas/program/seed_codegen.rs @@ -165,6 +165,7 @@ pub fn generate_client_seed_functions( seeds: syn::punctuated::Punctuated::new(), authority: None, inner_type: spec.inner_type.clone(), + is_zero_copy: spec.is_zero_copy, }; for auth_seed in authority_seeds { diff --git a/sdk-libs/macros/src/light_pdas/program/variant_enum.rs b/sdk-libs/macros/src/light_pdas/program/variant_enum.rs index e85eac0f7d..c146b61ba8 100644 --- a/sdk-libs/macros/src/light_pdas/program/variant_enum.rs +++ b/sdk-libs/macros/src/light_pdas/program/variant_enum.rs @@ -47,7 +47,7 @@ impl<'a> LightVariantBuilder<'a> { if self.pda_ctx_seeds.is_empty() { return Err(syn::Error::new( proc_macro2::Span::call_site(), - "#[rentfree_program] requires at least one Accounts struct with \ + "#[light_program] requires at least one Accounts struct with \ #[light_account(init)] fields.\n\n\ Make sure your program has:\n\ 1. An Accounts struct with #[derive(Accounts, LightAccounts)]\n\ @@ -75,6 +75,7 @@ impl<'a> LightVariantBuilder<'a> { pub fn build(&self) -> Result { self.validate()?; + let packed_data_structs = self.generate_packed_data_structs()?; let enum_def = self.generate_enum_def()?; let default_impl = self.generate_default_impl(); let data_hasher_impl = self.generate_data_hasher_impl(); @@ -84,8 +85,11 @@ impl<'a> LightVariantBuilder<'a> { let pack_impl = self.generate_pack_impl(); let unpack_impl = self.generate_unpack_impl()?; let light_account_data_struct = self.generate_light_account_data_struct(); + let decompressible_impls = self.generate_decompressible_account_impls()?; + let decompressible_enum_impl = self.generate_decompressible_account_enum_impl(); Ok(quote! { + #packed_data_structs #enum_def #default_impl #data_hasher_impl @@ -95,19 +99,72 @@ impl<'a> LightVariantBuilder<'a> { #pack_impl #unpack_impl #light_account_data_struct + #decompressible_impls + #decompressible_enum_impl }) } + /// Generate PackedXxxData structs for each account type. + /// + /// These structs wrap the packed data and seed indices, and implement + /// `DecompressibleAccount` for simple dispatch. + /// + /// For zero_copy accounts, the data field is `Vec` instead of a packed type, + /// since Pod types don't need Pubkey-to-index packing (they use `[u8; 32]` directly). + fn generate_packed_data_structs(&self) -> Result { + let mut structs = Vec::new(); + + for info in self.pda_ctx_seeds.iter() { + let variant_name = &info.variant_name; + let packed_data_struct_name = format_ident!("Packed{}Data", variant_name); + let ctx_fields = &info.ctx_seed_fields; + let params_only_fields = &info.params_only_seed_fields; + + // For zero_copy accounts, use Vec as the data type since Pod types + // don't need packing (they already use [u8; 32] instead of Pubkey) + let data_field_type = if info.is_zero_copy { + quote! { Vec } + } else { + let packed_inner_type = make_packed_type(&info.inner_type).ok_or_else(|| { + syn::Error::new_spanned(&info.inner_type, "invalid type path for packed type") + })?; + quote! { #packed_inner_type } + }; + + // Generate struct fields + let idx_fields = ctx_fields.iter().map(|field| { + let idx_field = format_ident!("{}_idx", field); + quote! { pub #idx_field: u8 } + }); + let params_fields = params_only_fields.iter().map(|(field, ty, _)| { + quote! { pub #field: #ty } + }); + + structs.push(quote! { + /// Packed data struct for #variant_name, wrapping packed data and seed indices. + #[derive(Clone, Debug, anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] + pub struct #packed_data_struct_name { + pub data: #data_field_type, + #(#idx_fields,)* + #(#params_fields,)* + } + }); + } + + Ok(quote! { #(#structs)* }) + } + /// Generate the enum definition with all variants. + /// + /// Packed variants now wrap PackedXxxData structs for simplified dispatch. + /// For zero_copy accounts, the unpacked variant stores `Vec` instead of the inner type, + /// since Pod types don't implement Borsh serialization required by the enum's derives. fn generate_enum_def(&self) -> Result { let mut account_variants_tokens = Vec::new(); for info in self.pda_ctx_seeds.iter() { let variant_name = &info.variant_name; - let inner_type = qualify_type_with_crate(&info.inner_type); let packed_variant_name = make_packed_variant_name(variant_name); - let packed_inner_type = make_packed_type(&info.inner_type).ok_or_else(|| { - syn::Error::new_spanned(&info.inner_type, "invalid type path for packed type") - })?; + let packed_data_struct_name = format_ident!("Packed{}Data", variant_name); let ctx_fields = &info.ctx_seed_fields; let params_only_fields = &info.params_only_seed_fields; @@ -118,18 +175,19 @@ impl<'a> LightVariantBuilder<'a> { quote! { #field: #ty } }); - let packed_ctx_fields = ctx_fields.iter().map(|field| { - let idx_field = format_ident!("{}_idx", field); - quote! { #idx_field: u8 } - }); - let packed_params_fields = params_only_fields.iter().map(|(field, ty, _)| { - quote! { #field: #ty } - }); - - account_variants_tokens.push(quote! { - #variant_name { data: #inner_type, #(#unpacked_ctx_fields,)* #(#unpacked_params_fields,)* }, - #packed_variant_name { data: #packed_inner_type, #(#packed_ctx_fields,)* #(#packed_params_fields,)* }, - }); + // For zero_copy accounts, store data as Vec since Pod types don't implement Borsh + if info.is_zero_copy { + account_variants_tokens.push(quote! { + #variant_name { data: Vec, #(#unpacked_ctx_fields,)* #(#unpacked_params_fields,)* }, + #packed_variant_name(#packed_data_struct_name), + }); + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + account_variants_tokens.push(quote! { + #variant_name { data: #inner_type, #(#unpacked_ctx_fields,)* #(#unpacked_params_fields,)* }, + #packed_variant_name(#packed_data_struct_name), + }); + } } let ctoken_variants = if self.include_ctoken { @@ -151,10 +209,11 @@ impl<'a> LightVariantBuilder<'a> { } /// Generate the Default implementation. + /// + /// For zero_copy accounts, defaults to an empty Vec since the unpacked variant stores bytes. fn generate_default_impl(&self) -> TokenStream { let first = &self.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_params_only_fields = &first.params_only_seed_fields; @@ -165,24 +224,44 @@ impl<'a> LightVariantBuilder<'a> { quote! { #field: <#ty as Default>::default() } }); + // For zero_copy accounts, use empty Vec as default + let data_default = if first.is_zero_copy { + quote! { Vec::new() } + } else { + let first_type = qualify_type_with_crate(&first.inner_type); + quote! { #first_type::default() } + }; + quote! { impl Default for LightAccountVariant { fn default() -> Self { - Self::#first_variant { data: #first_type::default(), #(#first_default_ctx_fields,)* #(#first_default_params_fields,)* } + Self::#first_variant { data: #data_default, #(#first_default_ctx_fields,)* #(#first_default_params_fields,)* } } } } } /// Generate the DataHasher implementation. + /// + /// Packed variants now use tuple syntax. + /// For zero_copy accounts, the unpacked variant stores `Vec`, so we hash the bytes directly. fn generate_data_hasher_impl(&self) -> TokenStream { let hash_match_arms = self.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! { - LightAccountVariant::#variant_name { data, .. } => <#inner_type as ::light_sdk::hasher::DataHasher>::hash::(data), - LightAccountVariant::#packed_variant_name { .. } => Err(::light_sdk::hasher::HasherError::EmptyInput), + + // For zero_copy accounts, hash the raw bytes since data is Vec + if info.is_zero_copy { + quote! { + LightAccountVariant::#variant_name { data, .. } => H::hashv(&[data.as_slice()]), + LightAccountVariant::#packed_variant_name(_) => Err(::light_sdk::hasher::HasherError::EmptyInput), + } + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + quote! { + LightAccountVariant::#variant_name { data, .. } => <#inner_type as ::light_sdk::hasher::DataHasher>::hash::(data), + LightAccountVariant::#packed_variant_name(_) => Err(::light_sdk::hasher::HasherError::EmptyInput), + } } }); @@ -218,44 +297,81 @@ impl<'a> LightVariantBuilder<'a> { } /// Generate the HasCompressionInfo implementation. + /// + /// Packed variants now use tuple syntax. + /// For zero_copy accounts, the unpacked variant stores `Vec` and cannot implement + /// HasCompressionInfo trait methods, so we return errors for those variants. fn generate_has_compression_info_impl(&self) -> TokenStream { let compression_info_match_arms = self.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! { - LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::compression_info(data), - LightAccountVariant::#packed_variant_name { .. } => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + + // For zero_copy accounts, unpacked variant stores Vec - cannot access compression info + if info.is_zero_copy { + quote! { + LightAccountVariant::#variant_name { .. } => Err(light_sdk::error::LightSdkError::ZeroCopyUnpackedVariant.into()), + LightAccountVariant::#packed_variant_name(_) => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + } + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + quote! { + LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::compression_info(data), + LightAccountVariant::#packed_variant_name(_) => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + } } }); let compression_info_mut_match_arms = self.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! { - LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::compression_info_mut(data), - LightAccountVariant::#packed_variant_name { .. } => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + + if info.is_zero_copy { + quote! { + LightAccountVariant::#variant_name { .. } => Err(light_sdk::error::LightSdkError::ZeroCopyUnpackedVariant.into()), + LightAccountVariant::#packed_variant_name(_) => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + } + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + quote! { + LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::compression_info_mut(data), + LightAccountVariant::#packed_variant_name(_) => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + } } }); let compression_info_mut_opt_match_arms = self.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! { - LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::compression_info_mut_opt(data), - LightAccountVariant::#packed_variant_name { .. } => panic!("compression_info_mut_opt not supported on packed variants"), + + if info.is_zero_copy { + quote! { + LightAccountVariant::#variant_name { .. } => panic!("compression_info_mut_opt not supported on zero_copy unpacked variants"), + LightAccountVariant::#packed_variant_name(_) => panic!("compression_info_mut_opt not supported on packed variants"), + } + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + quote! { + LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::compression_info_mut_opt(data), + LightAccountVariant::#packed_variant_name(_) => panic!("compression_info_mut_opt not supported on packed variants"), + } } }); let set_compression_info_none_match_arms = self.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! { - LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::set_compression_info_none(data), - LightAccountVariant::#packed_variant_name { .. } => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + + if info.is_zero_copy { + quote! { + LightAccountVariant::#variant_name { .. } => Err(light_sdk::error::LightSdkError::ZeroCopyUnpackedVariant.into()), + LightAccountVariant::#packed_variant_name(_) => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + } + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + quote! { + LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::interface::HasCompressionInfo>::set_compression_info_none(data), + LightAccountVariant::#packed_variant_name(_) => Err(light_sdk::error::LightSdkError::PackedVariantCompressionInfo.into()), + } } }); @@ -309,14 +425,26 @@ impl<'a> LightVariantBuilder<'a> { } /// Generate the Size implementation. + /// + /// Packed variants now use tuple syntax. + /// For zero_copy accounts, the unpacked variant stores `Vec` so we return its length. fn generate_size_impl(&self) -> TokenStream { let size_match_arms = self.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! { - LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::account::Size>::size(data), - LightAccountVariant::#packed_variant_name { .. } => Err(solana_program_error::ProgramError::InvalidAccountData), + + // For zero_copy accounts, return the Vec length + if info.is_zero_copy { + quote! { + LightAccountVariant::#variant_name { data, .. } => Ok(data.len()), + LightAccountVariant::#packed_variant_name(_) => Err(solana_program_error::ProgramError::InvalidAccountData), + } + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + quote! { + LightAccountVariant::#variant_name { data, .. } => <#inner_type as light_sdk::account::Size>::size(data), + LightAccountVariant::#packed_variant_name(_) => Err(solana_program_error::ProgramError::InvalidAccountData), + } } }); @@ -342,44 +470,20 @@ impl<'a> LightVariantBuilder<'a> { } /// Generate the Pack implementation. + /// + /// Packed variants now use tuple syntax wrapping PackedXxxData structs. + /// For zero_copy accounts, the unpacked variant stores `Vec` and packing from + /// unpacked is not supported (returns error). fn generate_pack_impl(&self) -> TokenStream { - let pack_match_arms: Vec<_> = self.pda_ctx_seeds.iter().map(|info| { - let variant_name = &info.variant_name; - let inner_type = qualify_type_with_crate(&info.inner_type); - let packed_variant_name = format_ident!("Packed{}", variant_name); - let ctx_fields = &info.ctx_seed_fields; - let params_only_fields = &info.params_only_seed_fields; - - let ctx_field_names: Vec<_> = ctx_fields.iter().collect(); - let idx_field_names: Vec<_> = ctx_fields.iter().map(|f| format_ident!("{}_idx", f)).collect(); - let pack_ctx_seeds: Vec<_> = ctx_fields.iter().map(|field| { - let idx_field = format_ident!("{}_idx", field); - quote! { let #idx_field = remaining_accounts.insert_or_get(*#field); } - }).collect(); - - let params_field_names: Vec<_> = params_only_fields.iter().map(|(f, _, _)| f).collect(); - - if ctx_fields.is_empty() && params_only_fields.is_empty() { - quote! { - LightAccountVariant::#packed_variant_name { .. } => Err(solana_program_error::ProgramError::InvalidAccountData), - LightAccountVariant::#variant_name { data, .. } => Ok(LightAccountVariant::#packed_variant_name { - data: <#inner_type as light_sdk::interface::Pack>::pack(data, remaining_accounts)?, - }), - } - } else { - quote! { - LightAccountVariant::#packed_variant_name { .. } => Err(solana_program_error::ProgramError::InvalidAccountData), - LightAccountVariant::#variant_name { data, #(#ctx_field_names,)* #(#params_field_names,)* .. } => { - #(#pack_ctx_seeds)* - Ok(LightAccountVariant::#packed_variant_name { - data: <#inner_type as light_sdk::interface::Pack>::pack(data, remaining_accounts)?, - #(#idx_field_names,)* - #(#params_field_names: *#params_field_names,)* - }) - }, - } - } - }).collect(); + let pack_match_arms: Vec<_> = self + .pda_ctx_seeds + .iter() + .map(|info| { + let seeds = + SeedFieldCollection::new(&info.ctx_seed_fields, &info.params_only_seed_fields); + generate_pack_match_arm(info, &seeds) + }) + .collect(); let ctoken_arms = if self.include_ctoken { quote! { @@ -407,58 +511,15 @@ impl<'a> LightVariantBuilder<'a> { } /// Generate the Unpack implementation. + /// + /// Packed variants now use tuple syntax - access inner struct fields via `inner.field`. + /// For zero_copy accounts, the unpacked variant stores `Vec` containing the Pod bytes. fn generate_unpack_impl(&self) -> Result { let mut unpack_match_arms = Vec::new(); for info in self.pda_ctx_seeds.iter() { - let variant_name = &info.variant_name; - let inner_type = &info.inner_type; - let packed_variant_name = make_packed_variant_name(variant_name); - let packed_inner_type = make_packed_type(inner_type).ok_or_else(|| { - syn::Error::new_spanned(inner_type, "invalid type path for packed type") - })?; - let ctx_fields = &info.ctx_seed_fields; - let params_only_fields = &info.params_only_seed_fields; - - let idx_field_names: Vec<_> = ctx_fields - .iter() - .map(|f| format_ident!("{}_idx", f)) - .collect(); - let ctx_field_names: Vec<_> = ctx_fields.iter().collect(); - let unpack_ctx_seeds: Vec<_> = ctx_fields - .iter() - .map(|field| { - let idx_field = format_ident!("{}_idx", field); - quote! { - let #field = *remaining_accounts - .get(*#idx_field as usize) - .ok_or(solana_program_error::ProgramError::InvalidAccountData)? - .key; - } - }) - .collect(); - - let params_field_names: Vec<_> = params_only_fields.iter().map(|(f, _, _)| f).collect(); - - if ctx_fields.is_empty() && params_only_fields.is_empty() { - unpack_match_arms.push(quote! { - LightAccountVariant::#packed_variant_name { data, .. } => Ok(LightAccountVariant::#variant_name { - data: <#packed_inner_type as light_sdk::interface::Unpack>::unpack(data, remaining_accounts)?, - }), - LightAccountVariant::#variant_name { .. } => Err(solana_program_error::ProgramError::InvalidAccountData), - }); - } else { - unpack_match_arms.push(quote! { - LightAccountVariant::#packed_variant_name { data, #(#idx_field_names,)* #(#params_field_names,)* .. } => { - #(#unpack_ctx_seeds)* - Ok(LightAccountVariant::#variant_name { - data: <#packed_inner_type as light_sdk::interface::Unpack>::unpack(data, remaining_accounts)?, - #(#ctx_field_names,)* - #(#params_field_names: *#params_field_names,)* - }) - }, - LightAccountVariant::#variant_name { .. } => Err(solana_program_error::ProgramError::InvalidAccountData), - }); - } + let seeds = + SeedFieldCollection::new(&info.ctx_seed_fields, &info.params_only_seed_fields); + unpack_match_arms.push(generate_unpack_match_arm(info, &seeds)?); } let ctoken_arms = if self.include_ctoken { @@ -497,6 +558,255 @@ impl<'a> LightVariantBuilder<'a> { } } } + + /// Generate DecompressibleAccount implementations for each PackedXxxData struct. + /// + /// Each impl provides: + /// - `is_token()` returning false (PDA variants are not tokens) + /// - `prepare()` that resolves indices, unpacks data, derives PDA, and calls + /// prepare_account_for_decompression_idempotent + fn generate_decompressible_account_impls(&self) -> Result { + let mut impls = Vec::new(); + + for info in self.pda_ctx_seeds.iter() { + let variant_name = &info.variant_name; + let packed_data_struct_name = format_ident!("Packed{}Data", variant_name); + let inner_type = qualify_type_with_crate(&info.inner_type); + let ctx_seeds_struct_name = format_ident!("{}CtxSeeds", variant_name); + let ctx_fields = &info.ctx_seed_fields; + let params_only_fields = &info.params_only_seed_fields; + + // Generate code to resolve idx fields to Pubkeys + let resolve_ctx_seeds: Vec<_> = ctx_fields + .iter() + .map(|field| { + let idx_field = format_ident!("{}_idx", field); + quote! { + let #field = *ctx.remaining_accounts + .get(self.#idx_field as usize) + .ok_or(solana_program_error::ProgramError::InvalidAccountData)? + .key; + } + }) + .collect(); + + // Generate CtxSeeds struct construction + let ctx_seeds_construction = if ctx_fields.is_empty() { + quote! { let ctx_seeds = #ctx_seeds_struct_name; } + } else { + let field_inits: Vec<_> = ctx_fields.iter().map(|f| quote! { #f }).collect(); + quote! { let ctx_seeds = #ctx_seeds_struct_name { #(#field_inits),* }; } + }; + + // Generate SeedParams from params-only fields + let seed_params_construction = if params_only_fields.is_empty() { + quote! { let seed_params = SeedParams::default(); } + } else { + let field_inits: Vec<_> = params_only_fields + .iter() + .map(|(field, _, _)| { + quote! { #field: std::option::Option::Some(self.#field) } + }) + .collect(); + quote! { + let seed_params = SeedParams { + #(#field_inits,)* + ..Default::default() + }; + } + }; + + // Generate data unpacking code based on whether this is a zero-copy account + // For zero_copy, use unpack_stripped to reconstruct from stripped bytes; for Borsh, use Unpack trait + let unpack_data_code = if info.is_zero_copy { + quote! { + // Reconstruct full Pod from stripped bytes (zeros at CompressionInfo offset) + let data: #inner_type = <#inner_type as light_sdk::interface::PodCompressionInfoField>::unpack_stripped(&self.data)?; + } + } else { + let packed_inner_type = make_packed_type(&info.inner_type).ok_or_else(|| { + syn::Error::new_spanned(&info.inner_type, "invalid type path for packed type") + })?; + quote! { + let data: #inner_type = <#packed_inner_type as light_sdk::interface::Unpack>::unpack( + &self.data, ctx.remaining_accounts + )?; + } + }; + + // Generate the decompression call based on whether this is a zero-copy account + let decompression_call = if info.is_zero_copy { + quote! { + light_sdk::interface::prepare_account_for_decompression_idempotent_pod::<#inner_type>( + ctx.program_id, + data, + compressed_meta, + solana_account, + ctx.rent_sponsor, + ctx.cpi_accounts, + &seed_refs[..len], + ctx.rent, + ctx.current_slot, + ).map_err(|e| e.into()) + } + } else { + quote! { + light_sdk::interface::prepare_account_for_decompression_idempotent::<#inner_type>( + ctx.program_id, + data, + compressed_meta, + solana_account, + ctx.rent_sponsor, + ctx.cpi_accounts, + &seed_refs[..len], + ctx.rent, + ctx.current_slot, + ).map_err(|e| e.into()) + } + }; + + impls.push(quote! { + impl light_sdk::interface::DecompressibleAccount for #packed_data_struct_name { + fn is_token(&self) -> bool { false } + + fn prepare<'a, 'info>( + self, + ctx: &light_sdk::interface::DecompressCtx<'a, 'info>, + solana_account: &solana_account_info::AccountInfo<'info>, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + index: usize, + ) -> std::result::Result< + std::option::Option, + solana_program_error::ProgramError + > { + // 1. Resolve idx fields to Pubkeys + #(#resolve_ctx_seeds)* + + // 2. Build CtxSeeds struct + #ctx_seeds_construction + + // 3. Build SeedParams + #seed_params_construction + + // 4. Unpack data + #unpack_data_code + + // 5. Derive PDA seeds + let (seeds_vec, derived_pda) = <#inner_type as light_sdk::interface::PdaSeedDerivation< + #ctx_seeds_struct_name, SeedParams + >>::derive_pda_seeds_with_accounts( + &data, ctx.program_id, &ctx_seeds, &seed_params + )?; + + // 6. Verify PDA matches + if derived_pda != *solana_account.key { + solana_msg::msg!( + "Derived PDA mismatch at {}: expected {:?}, got {:?}", + index, solana_account.key, derived_pda + ); + return Err(light_sdk::error::LightSdkError::ConstraintViolation.into()); + } + + // 7. Build seed refs and call appropriate decompression function + const MAX_SEEDS: usize = 16; + let mut seed_refs: [&[u8]; MAX_SEEDS] = [&[]; MAX_SEEDS]; + let len = seeds_vec.len().min(MAX_SEEDS); + for i in 0..len { + seed_refs[i] = seeds_vec[i].as_slice(); + } + + let compressed_meta = light_sdk::interface::into_compressed_meta_with_address( + meta, solana_account, ctx.address_space, ctx.program_id + ); + + #decompression_call + } + } + }); + } + + Ok(quote! { #(#impls)* }) + } + + /// Generate DecompressibleAccount implementation for the LightAccountVariant enum. + /// + /// - `is_token()` returns true for CToken variants, false for PDA variants + /// - `prepare()` delegates to the inner PackedXxxData struct's prepare method + fn generate_decompressible_account_enum_impl(&self) -> TokenStream { + let is_token_arms: Vec<_> = self + .pda_ctx_seeds + .iter() + .map(|info| { + let variant_name = &info.variant_name; + let packed_variant_name = format_ident!("Packed{}", variant_name); + quote! { + Self::#variant_name { .. } => false, + Self::#packed_variant_name(_) => false, + } + }) + .collect(); + + let prepare_arms: Vec<_> = self + .pda_ctx_seeds + .iter() + .map(|info| { + let variant_name = &info.variant_name; + let packed_variant_name = format_ident!("Packed{}", variant_name); + quote! { + Self::#packed_variant_name(inner) => inner.prepare(ctx, solana_account, meta, index), + Self::#variant_name { .. } => { + Err(light_sdk::error::LightSdkError::UnexpectedUnpackedVariant.into()) + } + } + }) + .collect(); + + let ctoken_is_token_arms = if self.include_ctoken { + quote! { + Self::PackedCTokenData(_) => true, + Self::CTokenData(_) => true, + } + } else { + quote! {} + }; + + let ctoken_prepare_arms = if self.include_ctoken { + quote! { + Self::PackedCTokenData(_) | Self::CTokenData(_) => { + Err(light_sdk::error::LightSdkError::TokenPrepareCalled.into()) + } + } + } else { + quote! {} + }; + + quote! { + impl light_sdk::interface::DecompressibleAccount for LightAccountVariant { + fn is_token(&self) -> bool { + match self { + #(#is_token_arms)* + #ctoken_is_token_arms + } + } + + fn prepare<'a, 'info>( + self, + ctx: &light_sdk::interface::DecompressCtx<'a, 'info>, + solana_account: &solana_account_info::AccountInfo<'info>, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + index: usize, + ) -> std::result::Result< + std::option::Option, + solana_program_error::ProgramError + > { + match self { + #(#prepare_arms)* + #ctoken_prepare_arms + } + } + } + } + } } /// Info about ctx.* seeds for a PDA type @@ -513,6 +823,9 @@ pub struct PdaCtxSeedInfo { /// Params-only seed fields (name, type, has_conversion) - seeds from params.* that don't exist on state /// The bool indicates whether a conversion method like to_le_bytes() is applied pub params_only_seed_fields: Vec<(Ident, Type, bool)>, + /// True if the field uses zero-copy serialization (AccountLoader). + /// When true, decompression uses prepare_account_for_decompression_idempotent_pod. + pub is_zero_copy: bool, } impl PdaCtxSeedInfo { @@ -522,6 +835,7 @@ impl PdaCtxSeedInfo { ctx_seed_fields: Vec, state_field_names: std::collections::HashSet, params_only_seed_fields: Vec<(Ident, Type, bool)>, + is_zero_copy: bool, ) -> Self { Self { variant_name, @@ -529,6 +843,7 @@ impl PdaCtxSeedInfo { ctx_seed_fields, state_field_names, params_only_seed_fields, + is_zero_copy, } } } @@ -583,53 +898,12 @@ impl<'a> TokenVariantBuilder<'a> { /// Generate the unpacked TokenAccountVariant enum. fn generate_unpacked_enum(&self) -> TokenStream { - let variants = self.token_seeds.iter().map(|spec| { - let variant_name = &spec.variant; - let ctx_fields = extract_ctx_fields_from_token_spec(spec); - - let fields = ctx_fields.iter().map(|field| { - quote! { #field: Pubkey } - }); - - if ctx_fields.is_empty() { - quote! { #variant_name, } - } else { - quote! { #variant_name { #(#fields,)* }, } - } - }); - - quote! { - #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] - pub enum TokenAccountVariant { - #(#variants)* - } - } + generate_token_variant_enum(self.token_seeds, "TokenAccountVariant", false) } /// Generate the packed PackedTokenAccountVariant enum. fn generate_packed_enum(&self) -> TokenStream { - let variants = self.token_seeds.iter().map(|spec| { - let variant_name = &spec.variant; - let ctx_fields = extract_ctx_fields_from_token_spec(spec); - - let fields = ctx_fields.iter().map(|field| { - let idx_field = format_ident!("{}_idx", field); - quote! { #idx_field: u8 } - }); - - if ctx_fields.is_empty() { - quote! { #variant_name, } - } else { - quote! { #variant_name { #(#fields,)* }, } - } - }); - - quote! { - #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] - pub enum PackedTokenAccountVariant { - #(#variants)* - } - } + generate_token_variant_enum(self.token_seeds, "PackedTokenAccountVariant", true) } /// Generate the Pack implementation for TokenAccountVariant. @@ -751,6 +1025,246 @@ impl<'a> TokenVariantBuilder<'a> { // HELPER FUNCTIONS // ============================================================================= +// ----------------------------------------------------------------------------- +// Seed Field Collection Helper (Phase 1) +// ----------------------------------------------------------------------------- + +/// Collected seed field identifiers for code generation. +/// +/// This struct centralizes the collection of context and params-only seed fields, +/// avoiding repeated collection logic across pack/unpack implementations. +struct SeedFieldCollection<'a> { + /// References to ctx.accounts.* field names + ctx_field_names: Vec<&'a Ident>, + /// Derived index field names (e.g., `field_idx` for `field`) + idx_field_names: Vec, + /// References to params-only field names + params_field_names: Vec<&'a Ident>, +} + +impl<'a> SeedFieldCollection<'a> { + /// Create a new SeedFieldCollection from context seed fields and params-only fields. + fn new( + ctx_fields: &'a [Ident], + params_only_fields: &'a [(Ident, Type, bool)], + ) -> Self { + Self { + ctx_field_names: ctx_fields.iter().collect(), + idx_field_names: ctx_fields + .iter() + .map(|f| format_ident!("{}_idx", f)) + .collect(), + params_field_names: params_only_fields.iter().map(|(f, _, _)| f).collect(), + } + } + + /// Returns true if there are any seeds (ctx or params). + fn has_seeds(&self) -> bool { + !self.ctx_field_names.is_empty() || !self.params_field_names.is_empty() + } +} + +// ----------------------------------------------------------------------------- +// Seed Packing/Unpacking Helpers (Phase 2) +// ----------------------------------------------------------------------------- + +/// Generate statements to pack context seeds into indices. +/// +/// For each ctx field, generates: `let field_idx = remaining_accounts.insert_or_get(*field);` +fn generate_pack_seed_statements(ctx_fields: &[Ident]) -> Vec { + ctx_fields + .iter() + .map(|field| { + let idx_field = format_ident!("{}_idx", field); + quote! { let #idx_field = remaining_accounts.insert_or_get(*#field); } + }) + .collect() +} + +/// Generate statements to unpack seed indices back to Pubkeys. +/// +/// For each ctx field, generates a statement that retrieves the Pubkey from remaining_accounts +/// using the stored index. +fn generate_unpack_seed_statements(ctx_fields: &[Ident]) -> Vec { + ctx_fields + .iter() + .map(|field| { + let idx_field = format_ident!("{}_idx", field); + quote! { + let #field = *remaining_accounts + .get(inner.#idx_field as usize) + .ok_or(solana_program_error::ProgramError::InvalidAccountData)? + .key; + } + }) + .collect() +} + +// ----------------------------------------------------------------------------- +// Pack/Unpack Match Arm Generators (Phase 3 & 4) +// ----------------------------------------------------------------------------- + +/// Generate a pack match arm for a single PDA variant. +/// +/// Handles both zero_copy and Borsh accounts, with or without seeds. +fn generate_pack_match_arm(info: &PdaCtxSeedInfo, seeds: &SeedFieldCollection) -> TokenStream { + let variant_name = &info.variant_name; + let packed_variant_name = format_ident!("Packed{}", variant_name); + let packed_data_struct_name = format_ident!("Packed{}Data", variant_name); + + // Data packing expression differs by account type + let data_expr = if info.is_zero_copy { + quote! { data.clone() } + } else { + let inner_type = qualify_type_with_crate(&info.inner_type); + quote! { <#inner_type as light_sdk::interface::Pack>::pack(data, remaining_accounts)? } + }; + + // Generate pack statements for ctx seeds + let pack_ctx_seeds = generate_pack_seed_statements(&info.ctx_seed_fields); + let idx_field_names = &seeds.idx_field_names; + let params_field_names = &seeds.params_field_names; + let ctx_field_names = &seeds.ctx_field_names; + + if seeds.has_seeds() { + quote! { + LightAccountVariant::#packed_variant_name(_) => Err(solana_program_error::ProgramError::InvalidAccountData), + LightAccountVariant::#variant_name { data, #(#ctx_field_names,)* #(#params_field_names,)* .. } => { + #(#pack_ctx_seeds)* + Ok(LightAccountVariant::#packed_variant_name(#packed_data_struct_name { + data: #data_expr, + #(#idx_field_names,)* + #(#params_field_names: *#params_field_names,)* + })) + }, + } + } else { + quote! { + LightAccountVariant::#packed_variant_name(_) => Err(solana_program_error::ProgramError::InvalidAccountData), + LightAccountVariant::#variant_name { data, .. } => { + Ok(LightAccountVariant::#packed_variant_name(#packed_data_struct_name { + data: #data_expr, + })) + }, + } + } +} + +/// Generate an unpack match arm for a single PDA variant. +/// +/// Handles both zero_copy and Borsh accounts, with or without seeds. +fn generate_unpack_match_arm( + info: &PdaCtxSeedInfo, + seeds: &SeedFieldCollection, +) -> Result { + let variant_name = &info.variant_name; + let packed_variant_name = make_packed_variant_name(variant_name); + let inner_type = &info.inner_type; + + // Data unpacking expression and assignment differ by account type + let (data_unpack, data_expr) = if info.is_zero_copy { + let qualified = qualify_type_with_crate(inner_type); + ( + quote! { + let full_pod = <#qualified as light_sdk::interface::PodCompressionInfoField>::unpack_stripped(&inner.data)?; + }, + quote! { bytemuck::bytes_of(&full_pod).to_vec() }, + ) + } else { + let packed_inner_type = make_packed_type(inner_type).ok_or_else(|| { + syn::Error::new_spanned(inner_type, "invalid type path for packed type") + })?; + ( + quote! { + let data = <#packed_inner_type as light_sdk::interface::Unpack>::unpack(&inner.data, remaining_accounts)?; + }, + quote! { data }, + ) + }; + + let unpack_ctx_seeds = generate_unpack_seed_statements(&info.ctx_seed_fields); + let ctx_field_names = &seeds.ctx_field_names; + let params_field_values: Vec<_> = seeds + .params_field_names + .iter() + .map(|f| quote! { #f: inner.#f }) + .collect(); + + if seeds.has_seeds() { + Ok(quote! { + LightAccountVariant::#packed_variant_name(inner) => { + #(#unpack_ctx_seeds)* + #data_unpack + Ok(LightAccountVariant::#variant_name { + data: #data_expr, + #(#ctx_field_names,)* + #(#params_field_values,)* + }) + }, + LightAccountVariant::#variant_name { .. } => Err(solana_program_error::ProgramError::InvalidAccountData), + }) + } else { + Ok(quote! { + LightAccountVariant::#packed_variant_name(inner) => { + #data_unpack + Ok(LightAccountVariant::#variant_name { + data: #data_expr, + }) + }, + LightAccountVariant::#variant_name { .. } => Err(solana_program_error::ProgramError::InvalidAccountData), + }) + } +} + +// ----------------------------------------------------------------------------- +// Token Variant Enum Helper (Phase 5) +// ----------------------------------------------------------------------------- + +/// Generate a token variant enum with customizable field types. +/// +/// This unifies the generation of TokenAccountVariant (Pubkey fields) and +/// PackedTokenAccountVariant (u8 index fields). +fn generate_token_variant_enum( + token_seeds: &[TokenSeedSpec], + enum_name: &str, + is_packed: bool, +) -> TokenStream { + let enum_ident = format_ident!("{}", enum_name); + let variants = token_seeds.iter().map(|spec| { + let variant_name = &spec.variant; + let ctx_fields = extract_ctx_fields_from_token_spec(spec); + + let fields: Vec<_> = ctx_fields + .iter() + .map(|field| { + if is_packed { + let idx_field = format_ident!("{}_idx", field); + quote! { #idx_field: u8 } + } else { + quote! { #field: Pubkey } + } + }) + .collect(); + + if ctx_fields.is_empty() { + quote! { #variant_name, } + } else { + quote! { #variant_name { #(#fields,)* }, } + } + }); + + quote! { + #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] + pub enum #enum_ident { + #(#variants)* + } + } +} + +// ----------------------------------------------------------------------------- +// Public Helper Functions +// ----------------------------------------------------------------------------- + /// Extract ctx.* field names from seed elements (both token seeds and authority seeds). /// /// Uses the visitor-based FieldExtractor for clean AST traversal. diff --git a/sdk-libs/sdk/Cargo.toml b/sdk-libs/sdk/Cargo.toml index 63adf65865..9d4057de61 100644 --- a/sdk-libs/sdk/Cargo.toml +++ b/sdk-libs/sdk/Cargo.toml @@ -28,6 +28,10 @@ sha256 = ["light-hasher/sha256", "light-compressed-account/sha256"] merkle-tree = ["light-concurrent-merkle-tree/solana"] anchor-discriminator = ["light-sdk-macros/anchor-discriminator"] custom-heap = ["light-heap"] +profile-program = [ +] +profile-heap = [ +] [dependencies] solana-pubkey = { workspace = true, features = ["borsh", "sha2", "curve25519"] } @@ -46,9 +50,11 @@ solana-program = { workspace = true, optional = true } num-bigint = { workspace = true } borsh = { workspace = true } +bytemuck = { workspace = true } thiserror = { workspace = true } bincode = "1" +light-program-profiler = { workspace = true } light-sdk-macros = { workspace = true } light-sdk-types = { workspace = true, features = ["std"] } light-macros = { workspace = true } diff --git a/sdk-libs/sdk/src/account.rs b/sdk-libs/sdk/src/account.rs index 0a7f139fcc..46b2e039ac 100644 --- a/sdk-libs/sdk/src/account.rs +++ b/sdk-libs/sdk/src/account.rs @@ -675,54 +675,7 @@ pub mod __internal { input_account_meta: &impl CompressedAccountMetaTrait, input_account: A, ) -> Result { - let input_account_info = { - // For HASH_FLAT = true, use direct serialization - let data = input_account - .try_to_vec() - .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; - let mut input_data_hash = H::hash(data.as_slice()) - .map_err(LightSdkError::from) - .map_err(ProgramError::from)?; - input_data_hash[0] = 0; - let tree_info = input_account_meta.get_tree_info(); - InAccountInfo { - data_hash: input_data_hash, - lamports: input_account_meta.get_lamports().unwrap_or_default(), - merkle_context: PackedMerkleContext { - merkle_tree_pubkey_index: tree_info.merkle_tree_pubkey_index, - queue_pubkey_index: tree_info.queue_pubkey_index, - leaf_index: tree_info.leaf_index, - prove_by_index: tree_info.prove_by_index, - }, - root_index: input_account_meta.get_root_index().unwrap_or_default(), - discriminator: A::LIGHT_DISCRIMINATOR, - } - }; - let output_account_info = { - let output_merkle_tree_index = input_account_meta - .get_output_state_tree_index() - .ok_or(LightSdkError::OutputStateTreeIndexIsNone) - .map_err(ProgramError::from)?; - OutAccountInfo { - lamports: input_account_meta.get_lamports().unwrap_or_default(), - output_merkle_tree_index, - discriminator: A::LIGHT_DISCRIMINATOR, - ..Default::default() - } - }; - - Ok(Self { - owner: owner.to_solana_pubkey(), - account: input_account, - account_info: CompressedAccountInfo { - address: input_account_meta.get_address(), - input: Some(input_account_info), - output: Some(output_account_info), - }, - should_remove_data: false, - read_only_account_hash: None, - _hasher: PhantomData, - }) + Ok(Self::new_mut_inner(owner, input_account_meta, input_account)?.0) } // TODO: add in a different pr and release @@ -1064,5 +1017,66 @@ pub mod __internal { Ok(None) } } + + pub(crate) fn new_mut_inner( + owner: &impl crate::PubkeyTrait, + input_account_meta: &impl CompressedAccountMetaTrait, + input_account: A, + ) -> Result<(LightAccountInner, Vec), ProgramError> { + let (input_account_info, data) = { + // For HASH_FLAT = true, use direct serialization + let data = input_account + .try_to_vec() + .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; + let mut input_data_hash = H::hash(data.as_slice()) + .map_err(LightSdkError::from) + .map_err(ProgramError::from)?; + input_data_hash[0] = 0; + let tree_info = input_account_meta.get_tree_info(); + ( + InAccountInfo { + data_hash: input_data_hash, + lamports: input_account_meta.get_lamports().unwrap_or_default(), + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_info.merkle_tree_pubkey_index, + queue_pubkey_index: tree_info.queue_pubkey_index, + leaf_index: tree_info.leaf_index, + prove_by_index: tree_info.prove_by_index, + }, + root_index: input_account_meta.get_root_index().unwrap_or_default(), + discriminator: A::LIGHT_DISCRIMINATOR, + }, + data, + ) + }; + let output_account_info = { + let output_merkle_tree_index = input_account_meta + .get_output_state_tree_index() + .ok_or(LightSdkError::OutputStateTreeIndexIsNone) + .map_err(ProgramError::from)?; + OutAccountInfo { + lamports: input_account_meta.get_lamports().unwrap_or_default(), + output_merkle_tree_index, + discriminator: A::LIGHT_DISCRIMINATOR, + ..Default::default() + } + }; + + Ok(( + LightAccountInner { + owner: owner.to_solana_pubkey(), + account: input_account, + account_info: CompressedAccountInfo { + address: input_account_meta.get_address(), + input: Some(input_account_info), + output: Some(output_account_info), + }, + should_remove_data: false, + read_only_account_hash: None, + _hasher: PhantomData, + }, + data, + )) + } } } diff --git a/sdk-libs/sdk/src/error.rs b/sdk-libs/sdk/src/error.rs index d47c5bb1e6..e56392e499 100644 --- a/sdk-libs/sdk/src/error.rs +++ b/sdk-libs/sdk/src/error.rs @@ -115,6 +115,10 @@ pub enum LightSdkError { CTokenCompressionInfo, #[error("Unexpected unpacked variant during decompression")] UnexpectedUnpackedVariant, + #[error("Token variant's prepare() method was called (tokens use separate handling)")] + TokenPrepareCalled, + #[error("Cannot access compression_info on zero_copy unpacked variant (stores raw bytes)")] + ZeroCopyUnpackedVariant, } impl From for ProgramError { @@ -208,6 +212,8 @@ impl From for u32 { LightSdkError::PackedVariantCompressionInfo => 16045, LightSdkError::CTokenCompressionInfo => 16046, LightSdkError::UnexpectedUnpackedVariant => 16047, + LightSdkError::TokenPrepareCalled => 16048, + LightSdkError::ZeroCopyUnpackedVariant => 16049, } } } diff --git a/sdk-libs/sdk/src/interface/close.rs b/sdk-libs/sdk/src/interface/close.rs index a240d3aae3..d19d8390b8 100644 --- a/sdk-libs/sdk/src/interface/close.rs +++ b/sdk-libs/sdk/src/interface/close.rs @@ -5,7 +5,7 @@ use crate::error::{LightSdkError, Result}; // close native solana account pub fn close<'info>( info: &mut AccountInfo<'info>, - sol_destination: AccountInfo<'info>, + sol_destination: &AccountInfo<'info>, ) -> Result<()> { let system_program_id = solana_pubkey::pubkey!("11111111111111111111111111111111"); diff --git a/sdk-libs/sdk/src/interface/compress_account.rs b/sdk-libs/sdk/src/interface/compress_account.rs index 1272c46083..a3e93d9f72 100644 --- a/sdk-libs/sdk/src/interface/compress_account.rs +++ b/sdk-libs/sdk/src/interface/compress_account.rs @@ -121,6 +121,10 @@ where std::borrow::Cow::Owned(data) => data, }; compressed_account.account = compressed_data; + // Set compression_info to compressed state before hashing + // This ensures the hash includes the compressed state marker + *compressed_account.account.compression_info_mut_opt() = + Some(crate::compressible::compression_info::CompressionInfo::compressed()); { use crate::interface::compression_info::CompressedInitSpace; let __lp_size = 8 + ::COMPRESSED_INIT_SPACE; @@ -135,3 +139,206 @@ where compressed_account.to_account_info() } + +/// Prepare Pod (zero-copy) account for compression. +/// +/// This function is the Pod equivalent of `prepare_account_for_compression`, +/// designed for accounts that use `bytemuck::Pod` instead of Borsh serialization. +/// +/// # Key Differences from Borsh Version +/// +/// - Uses `bytemuck::bytes_of()` instead of Borsh serialization +/// - Uses `core::mem::size_of::()` for static size calculation +/// - Writes Pod bytes directly instead of serializing +/// - More efficient for accounts with fixed-size layout +/// +/// # Type Requirements +/// +/// - `A` must implement `bytemuck::Pod` and `bytemuck::Zeroable` +/// - `A` must be `#[repr(C)]` for predictable field layout +/// - `A` must implement `PodCompressionInfoField` for compression state management +/// +/// # Arguments +/// * `program_id` - The program that owns the account +/// * `account_info` - The account to compress +/// * `account_data` - Mutable reference to the Pod account data +/// * `compressed_account_meta` - Metadata for the compressed account +/// * `cpi_accounts` - Accounts for CPI to light system program +/// * `address_space` - Address space for validation +#[cfg(feature = "v2")] +pub fn prepare_account_for_compression_pod<'info, A>( + program_id: &Pubkey, + account_info: &AccountInfo<'info>, + account_data: &mut A, + compressed_account_meta: &CompressedAccountMetaNoLamportsNoAddress, + _cpi_accounts: &CpiAccounts<'_, 'info>, + address_space: &[Pubkey], +) -> std::result::Result +where + A: bytemuck::Pod + + bytemuck::Zeroable + + Copy + + LightDiscriminator + + crate::interface::compression_info::PodCompressionInfoField + + Default, +{ + use crate::instruction::account_meta::CompressedAccountMetaTrait; + use crate::interface::compression_info::{CompressionInfo as SdkCompressionInfo, CompressionState}; + use light_compressed_account::{ + address::derive_address, + compressed_account::PackedMerkleContext, + instruction_data::with_account_info::{InAccountInfo, OutAccountInfo}, + }; + use light_hasher::{Hasher, Sha256}; + + // Default data hash for empty accounts (same as in account.rs) + const DEFAULT_DATA_HASH: [u8; 32] = [0u8; 32]; + + // v2 address derive using PDA as seed + let derived_c_pda = derive_address( + &account_info.key.to_bytes(), + &address_space[0].to_bytes(), + &program_id.to_bytes(), + ); + + let meta_with_address = CompressedAccountMeta { + tree_info: compressed_account_meta.tree_info, + address: derived_c_pda, + output_state_tree_index: compressed_account_meta.output_state_tree_index, + }; + + let current_slot = Clock::get()?.slot; + // Rent-function gating: account must be compressible w.r.t. rent function (current+next epoch) + let bytes = account_info.data_len() as u64; + let current_lamports = account_info.lamports(); + let rent_exemption_lamports = Rent::get() + .map_err(|_| LightSdkError::ConstraintViolation)? + .minimum_balance(bytes as usize); + + // Access the SDK compression info field directly (24 bytes) + let compression_info_offset = A::COMPRESSION_INFO_OFFSET; + let account_bytes = bytemuck::bytes_of(account_data); + let compression_info_bytes = + &account_bytes[compression_info_offset..compression_info_offset + core::mem::size_of::()]; + let sdk_ci: &SdkCompressionInfo = bytemuck::from_bytes(compression_info_bytes); + + let last_claimed_slot = sdk_ci.last_claimed_slot; + let rent_cfg = sdk_ci.rent_config; + let state = AccountRentState { + num_bytes: bytes, + current_slot, + current_lamports, + last_claimed_slot, + }; + if state + .is_compressible(&rent_cfg, rent_exemption_lamports) + .is_none() + { + msg!( + "prepare_account_for_compression_pod failed: \ + Account is not compressible by rent function. \ + slot: {}, lamports: {}, bytes: {}, rent_exemption_lamports: {}, last_claimed_slot: {}, rent_config: {:?}", + current_slot, + current_lamports, + bytes, + rent_exemption_lamports, + last_claimed_slot, + rent_cfg + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // Set compression state to compressed in the account data + // We need to modify the Pod struct in place + { + let mut data = account_info + .try_borrow_mut_data() + .map_err(|_| LightSdkError::ConstraintViolation)?; + + // Skip discriminator (8 bytes) to get to the Pod data + let discriminator_len = A::LIGHT_DISCRIMINATOR.len(); + let pod_data = &mut data[discriminator_len..]; + + // Mark as compressed using SDK CompressionInfo (24 bytes) + let compressed_info = SdkCompressionInfo { + last_claimed_slot: sdk_ci.last_claimed_slot, + lamports_per_write: sdk_ci.lamports_per_write, + config_version: sdk_ci.config_version, + state: CompressionState::Compressed, // Mark as compressed + _padding: 0, + rent_config: sdk_ci.rent_config, + }; + + let info_bytes = bytemuck::bytes_of(&compressed_info); + let offset = A::COMPRESSION_INFO_OFFSET; + let end = offset + core::mem::size_of::(); + pod_data[offset..end].copy_from_slice(info_bytes); + } + + // Update the local copy with CANONICAL compressed CompressionInfo for hashing + // Use CompressionInfo::compressed() for hash consistency with decompression + // (decompression uses unpack_stripped which inserts the same canonical bytes) + let mut compressed_data = *account_data; + { + let compressed_bytes: &mut [u8] = bytemuck::bytes_of_mut(&mut compressed_data); + let offset = A::COMPRESSION_INFO_OFFSET; + let end = offset + core::mem::size_of::(); + + // Use canonical compressed value (consistent with Borsh path) + let compressed_info = SdkCompressionInfo::compressed(); + let info_bytes = bytemuck::bytes_of(&compressed_info); + compressed_bytes[offset..end].copy_from_slice(info_bytes); + } + + // Hash the FULL bytes for output hash calculation (consistent with Borsh path) + // Discriminator is NOT included in hash per protocol convention + let compressed_bytes = bytemuck::bytes_of(&compressed_data); + let mut output_data_hash = Sha256::hash(compressed_bytes).map_err(LightSdkError::from)?; + output_data_hash[0] = 0; // Zero first byte per protocol convention + + // Strip CompressionInfo bytes to save 24 bytes per account in instruction data + // The hash is computed from full bytes, but we only transmit stripped bytes + let stripped_bytes = A::pack_stripped(&compressed_data); + + // Size check + let account_size = 8 + core::mem::size_of::(); + if account_size > 800 { + msg!( + "Compressed account would exceed 800-byte limit ({} bytes)", + account_size + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // Build input account info - represents the empty compressed account from init + // This is required for the system program to find the address in context.addresses + let tree_info = compressed_account_meta.tree_info; + let input_account_info = InAccountInfo { + data_hash: DEFAULT_DATA_HASH, + lamports: 0, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_info.merkle_tree_pubkey_index, + queue_pubkey_index: tree_info.queue_pubkey_index, + leaf_index: tree_info.leaf_index, + prove_by_index: tree_info.prove_by_index, + }, + root_index: compressed_account_meta.get_root_index().unwrap_or_default(), + discriminator: [0u8; 8], // Empty account marker + }; + + // Build output account info for compression + // Use stripped_bytes which saves 24 bytes (CompressionInfo size) per account + let output_account_info = OutAccountInfo { + lamports: 0, + output_merkle_tree_index: meta_with_address.output_state_tree_index, + discriminator: A::LIGHT_DISCRIMINATOR, + data: stripped_bytes, + data_hash: output_data_hash, + }; + + Ok(CompressedAccountInfo { + address: Some(meta_with_address.address), + input: Some(input_account_info), + output: Some(output_account_info), + }) +} diff --git a/sdk-libs/sdk/src/interface/compress_account_on_init.rs b/sdk-libs/sdk/src/interface/compress_account_on_init.rs index d4122db070..8f2966ed1f 100644 --- a/sdk-libs/sdk/src/interface/compress_account_on_init.rs +++ b/sdk-libs/sdk/src/interface/compress_account_on_init.rs @@ -114,3 +114,178 @@ where compressed_account.to_account_info() } + +/// Prepare a compressed Pod (zero-copy) account on init. +/// +/// This function is the Pod equivalent of `prepare_compressed_account_on_init`, +/// designed for accounts that use `bytemuck::Pod` instead of Borsh serialization. +/// +/// Does NOT close the PDA, does NOT invoke CPI. +/// +/// # Key Differences from Borsh Version +/// +/// - Uses `bytemuck::bytes_of()` instead of Borsh serialization +/// - Uses `core::mem::size_of::()` for static size calculation +/// - Writes Pod bytes directly instead of serializing +/// - Uses non-optional `CompressionInfo` where `config_account_version=0` means uninitialized +/// +/// # Type Requirements +/// +/// - `A` must implement `bytemuck::Pod` and `bytemuck::Zeroable` +/// - `A` must be `#[repr(C)]` for predictable field layout +/// - `A` must implement `PodCompressionInfoField` for compression state management +/// +/// # Arguments +/// * `account_info` - The PDA AccountInfo +/// * `account_data` - Mutable reference to Pod account data +/// * `compression_config` - Configuration for compression parameters +/// * `address` - The address for the compressed account +/// * `new_address_param` - Address parameters for the compressed account +/// * `output_state_tree_index` - Output state tree index +/// * `cpi_accounts` - Accounts for validation +/// * `address_space` - Address space for validation (can contain multiple tree pubkeys) +/// * `with_data` - If true, copies account data to compressed account, if false, creates empty +#[allow(clippy::too_many_arguments)] +#[cfg(feature = "v2")] +pub fn prepare_compressed_account_on_init_pod<'info, A>( + account_info: &AccountInfo<'info>, + account_data: &mut A, + compression_config: &crate::interface::LightConfig, + address: [u8; 32], + new_address_param: NewAddressParamsAssignedPacked, + output_state_tree_index: u8, + cpi_accounts: &CpiAccounts<'_, 'info>, + address_space: &[Pubkey], + with_data: bool, +) -> std::result::Result +where + A: bytemuck::Pod + + bytemuck::Zeroable + + Copy + + LightDiscriminator + + crate::interface::compression_info::PodCompressionInfoField + + Default, +{ + use crate::interface::compression_info::{CompressionInfo as SdkCompressionInfo, CompressionState}; + use light_compressed_account::instruction_data::with_account_info::OutAccountInfo; + use light_hasher::{Hasher, Sha256}; + use solana_sysvar::{clock::Clock, Sysvar}; + + // Validate address tree is in allowed address space + let tree = cpi_accounts + .get_tree_account_info(new_address_param.address_merkle_tree_account_index as usize) + .map_err(|_| { + msg!( + "Failed to get tree account at index {}", + new_address_param.address_merkle_tree_account_index + ); + LightSdkError::ConstraintViolation + })? + .pubkey(); + if !address_space.iter().any(|a| a == &tree) { + msg!("Address tree {} not in allowed address space", tree); + return Err(LightSdkError::ConstraintViolation.into()); + } + + let current_slot = Clock::get()?.slot; + + // Create SDK CompressionInfo from config (24 bytes) + // state = Decompressed means initialized/decompressed + // state = Compressed means compressed + let base_compression_info = SdkCompressionInfo { + last_claimed_slot: current_slot, + lamports_per_write: compression_config.write_top_up, // Already u32 in LightConfig + config_version: (compression_config.version as u16).max(1), // Ensure at least 1 for initialized + state: CompressionState::Decompressed, + _padding: 0, + rent_config: compression_config.rent_config, + }; + + // If with_data, mark as compressed + let final_compression_info = if with_data { + SdkCompressionInfo { + state: CompressionState::Compressed, // Compressed state + ..base_compression_info + } + } else { + base_compression_info + }; + + // Write compression info to account data in memory. + // For AccountLoader (zero-copy), account_data is a mutable reference to the + // account buffer (after discriminator), so this writes directly to the account. + { + let account_bytes: &mut [u8] = bytemuck::bytes_of_mut(account_data); + let offset = A::COMPRESSION_INFO_OFFSET; + let end = offset + core::mem::size_of::(); + let info_bytes = bytemuck::bytes_of(&final_compression_info); + account_bytes[offset..end].copy_from_slice(info_bytes); + } + + let _owner_program_id = cpi_accounts.self_program_id(); + let _ = account_info; // Keep for API consistency with non-pod version + + if with_data { + // Create a copy with CANONICAL compressed CompressionInfo for hashing + // Use CompressionInfo::compressed() for hash consistency with decompression + // (decompression uses unpack_stripped which inserts the same canonical bytes) + let mut hash_data = *account_data; + { + let hash_bytes: &mut [u8] = bytemuck::bytes_of_mut(&mut hash_data); + let offset = A::COMPRESSION_INFO_OFFSET; + let end = offset + core::mem::size_of::(); + let canonical_compressed = SdkCompressionInfo::compressed(); + let info_bytes = bytemuck::bytes_of(&canonical_compressed); + hash_bytes[offset..end].copy_from_slice(info_bytes); + } + + // Hash the FULL bytes for output hash calculation (consistent with Borsh path) + // Discriminator is NOT included in hash per protocol convention + let full_bytes = bytemuck::bytes_of(&hash_data); + let mut output_data_hash = Sha256::hash(full_bytes).map_err(LightSdkError::from)?; + output_data_hash[0] = 0; // Zero first byte per protocol convention + + // Strip CompressionInfo bytes to save 24 bytes per account in instruction data + // The hash is computed from full bytes, but we only transmit stripped bytes + let stripped_bytes = A::pack_stripped(&hash_data); + + // Size check + let account_size = 8 + core::mem::size_of::(); + if account_size > 800 { + msg!( + "Compressed account would exceed 800-byte limit ({} bytes)", + account_size + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // Use stripped_bytes which saves 24 bytes (CompressionInfo size) per account + let output_account_info = OutAccountInfo { + lamports: 0, + output_merkle_tree_index: output_state_tree_index, + discriminator: A::LIGHT_DISCRIMINATOR, + data: stripped_bytes, + data_hash: output_data_hash, + }; + + Ok(CompressedAccountInfo { + address: Some(address), + input: None, + output: Some(output_account_info), + }) + } else { + // Create empty compressed account (no data, just address registration) + // Use [0u8; 8] discriminator for empty accounts (consistent with Borsh version) + Ok(CompressedAccountInfo { + address: Some(address), + input: None, + output: Some(OutAccountInfo { + lamports: 0, + output_merkle_tree_index: output_state_tree_index, + discriminator: [0u8; 8], + data: vec![], + data_hash: [0u8; 32], + }), + }) + } +} diff --git a/sdk-libs/sdk/src/interface/compress_runtime.rs b/sdk-libs/sdk/src/interface/compress_runtime.rs index 19842aba33..42f03cfa97 100644 --- a/sdk-libs/sdk/src/interface/compress_runtime.rs +++ b/sdk-libs/sdk/src/interface/compress_runtime.rs @@ -4,7 +4,6 @@ use light_sdk_types::{ instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, CpiSigner, }; use solana_account_info::AccountInfo; -use solana_msg::msg; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; @@ -46,21 +45,10 @@ where let compression_config = crate::interface::LightConfig::load_checked(ctx.config(), program_id)?; - if *ctx.rent_sponsor().key != compression_config.rent_sponsor { - msg!( - "invalid rent sponsor {:?} != {:?}, expected", - *ctx.rent_sponsor().key, - compression_config.rent_sponsor - ); - return Err(ProgramError::Custom(0)); - } - if *ctx.compression_authority().key != compression_config.compression_authority { - msg!( - "invalid rent sponsor {:?} != {:?}, expected", - *ctx.compression_authority().key, - compression_config.compression_authority - ); - return Err(ProgramError::Custom(0)); + if *ctx.rent_sponsor().key != compression_config.rent_sponsor + || *ctx.compression_authority().key != compression_config.compression_authority + { + return Err(crate::error::LightSdkError::ConstraintViolation.into()); } let system_accounts_offset_usize = system_accounts_offset as usize; @@ -80,19 +68,13 @@ where Vec::with_capacity(compressed_accounts.len()); let mut pda_indices_to_close: Vec = Vec::with_capacity(compressed_accounts.len()); - let system_accounts_start = cpi_accounts.system_accounts_end_offset(); - let account_infos = cpi_accounts.to_account_infos(); - let all_post_system = account_infos - .get(system_accounts_start..) - .ok_or_else(|| ProgramError::from(crate::error::LightSdkError::ConstraintViolation))?; - // PDAs are at the end of remaining_accounts, after all the merkle tree/queue accounts - let pda_start_in_all_accounts = all_post_system + let pda_accounts_start = remaining_accounts .len() .checked_sub(compressed_accounts.len()) .ok_or_else(|| ProgramError::from(crate::error::LightSdkError::ConstraintViolation))?; - let solana_accounts = all_post_system - .get(pda_start_in_all_accounts..) + let solana_accounts = remaining_accounts + .get(pda_accounts_start..) .ok_or_else(|| ProgramError::from(crate::error::LightSdkError::ConstraintViolation))?; for (i, account_info) in solana_accounts.iter().enumerate() { @@ -125,7 +107,7 @@ where for idx in pda_indices_to_close { let mut info = solana_accounts[idx].clone(); - crate::interface::close::close(&mut info, ctx.rent_sponsor().clone()) + crate::interface::close::close(&mut info, ctx.rent_sponsor()) .map_err(ProgramError::from)?; } } diff --git a/sdk-libs/sdk/src/interface/compression_info.rs b/sdk-libs/sdk/src/interface/compression_info.rs index 852673f1ca..69ad763407 100644 --- a/sdk-libs/sdk/src/interface/compression_info.rs +++ b/sdk-libs/sdk/src/interface/compression_info.rs @@ -4,6 +4,8 @@ use light_compressible::rent::RentConfig; use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; use solana_account_info::AccountInfo; use solana_clock::Clock; +use solana_cpi::invoke; +use solana_instruction::{AccountMeta, Instruction}; use solana_pubkey::Pubkey; use solana_sysvar::Sysvar; @@ -40,6 +42,90 @@ pub trait HasCompressionInfo { fn set_compression_info_none(&mut self) -> Result<(), ProgramError>; } +/// Simple field accessor trait for types with a `compression_info: Option` field. +/// Implement this trait and get `HasCompressionInfo` for free via blanket impl. +pub trait CompressionInfoField { + /// True if `compression_info` is the first field, false if last. + /// This enables efficient serialization by skipping at a known offset. + const COMPRESSION_INFO_FIRST: bool; + + fn compression_info_field(&self) -> &Option; + fn compression_info_field_mut(&mut self) -> &mut Option; + + /// Write `Some(CompressionInfo::new_decompressed())` directly into serialized account data. + /// + /// This avoids re-serializing the entire account by writing only the compression_info + /// bytes at the correct offset (first or last field position). + /// + /// # Arguments + /// * `data` - Mutable slice of the serialized account data (WITHOUT discriminator prefix) + /// * `current_slot` - Current slot for initializing `last_claimed_slot` + /// + /// # Returns + /// * `Ok(())` on success + /// * `Err` if serialization fails or data is too small + fn write_decompressed_info_to_slice( + data: &mut [u8], + current_slot: u64, + ) -> Result<(), ProgramError> { + use crate::AnchorSerialize; + + let info = CompressionInfo { + last_claimed_slot: current_slot, + lamports_per_write: 0, + config_version: 0, + state: CompressionState::Decompressed, + _padding: 0, + rent_config: light_compressible::rent::RentConfig::default(), + }; + + // Option serializes as: 1 byte discriminant + T if Some + let option_size = OPTION_COMPRESSION_INFO_SPACE; + + let offset = if Self::COMPRESSION_INFO_FIRST { + 0 + } else { + data.len().saturating_sub(option_size) + }; + + if data.len() < offset + option_size { + return Err(ProgramError::AccountDataTooSmall); + } + + let target = &mut data[offset..offset + option_size]; + // Write Some discriminant + target[0] = 1; + // Write CompressionInfo + info.serialize(&mut &mut target[1..]) + .map_err(|_| ProgramError::BorshIoError("compression_info serialize failed".into()))?; + + Ok(()) + } +} + +impl HasCompressionInfo for T { + fn compression_info(&self) -> Result<&CompressionInfo, ProgramError> { + self.compression_info_field() + .as_ref() + .ok_or(crate::error::LightSdkError::MissingCompressionInfo.into()) + } + + fn compression_info_mut(&mut self) -> Result<&mut CompressionInfo, ProgramError> { + self.compression_info_field_mut() + .as_mut() + .ok_or(crate::error::LightSdkError::MissingCompressionInfo.into()) + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + self.compression_info_field_mut() + } + + fn set_compression_info_none(&mut self) -> Result<(), ProgramError> { + *self.compression_info_field_mut() = None; + Ok(()) + } +} + /// Account space when compressed. pub trait CompressedInitSpace { const COMPRESSED_INIT_SPACE: usize; @@ -58,29 +144,77 @@ pub trait CompressAs { fn compress_as(&self) -> Cow<'_, Self::Output>; } -#[derive(Debug, Clone, Default, PartialEq, AnchorSerialize, AnchorDeserialize)] +/// SDK CompressionInfo - a compact 24-byte struct for custom zero-copy PDAs. +/// +/// This is the lightweight version of compression info used in the SDK. +/// CToken has its own compression handling via `light_compressible::CompressionInfo`. +/// +/// # Memory Layout (24 bytes with #[repr(C)]) +/// - `last_claimed_slot`: u64 @ offset 0 (8 bytes, 8-byte aligned) +/// - `lamports_per_write`: u32 @ offset 8 (4 bytes) +/// - `config_version`: u16 @ offset 12 (2 bytes) +/// - `state`: CompressionState @ offset 14 (1 byte) +/// - `_padding`: u8 @ offset 15 (1 byte) +/// - `rent_config`: RentConfig @ offset 16 (8 bytes, 2-byte aligned) +/// +/// Fields are ordered for optimal alignment to achieve exactly 24 bytes. +#[derive(Debug, Clone, Copy, Default, PartialEq, AnchorSerialize, AnchorDeserialize)] +#[repr(C)] pub struct CompressionInfo { - /// Version of the compressible config used to initialize this account. - pub config_version: u16, - /// Lamports to top up on each write (from config, stored per-account to avoid passing config on every write) - pub lamports_per_write: u32, /// Slot when rent was last claimed (epoch boundary accounting). pub last_claimed_slot: u64, - /// Rent function parameters for determining compressibility/claims. - pub rent_config: RentConfig, + /// Lamports to top up on each write (from config, stored per-account to avoid passing config on every write) + pub lamports_per_write: u32, + /// Version of the compressible config used to initialize this account. + pub config_version: u16, /// Account compression state. pub state: CompressionState, + pub _padding: u8, + /// Rent function parameters for determining compressibility/claims. + pub rent_config: RentConfig, } -#[derive(Debug, Clone, Default, AnchorSerialize, AnchorDeserialize, PartialEq)] +// Safety: CompressionInfo is #[repr(C)] with all Pod fields and no padding gaps +unsafe impl bytemuck::Pod for CompressionInfo {} +unsafe impl bytemuck::Zeroable for CompressionInfo {} + +/// Compression state for SDK CompressionInfo. +/// +/// This enum uses #[repr(u8)] for Pod compatibility: +/// - Uninitialized = 0 (default, account not yet set up) +/// - Decompressed = 1 (account is decompressed/active on Solana) +/// - Compressed = 2 (account is compressed in Merkle tree) +#[derive(Debug, Clone, Copy, Default, AnchorSerialize, AnchorDeserialize, PartialEq, Eq)] +#[repr(u8)] pub enum CompressionState { #[default] - Uninitialized, - Decompressed, - Compressed, + Uninitialized = 0, + Decompressed = 1, + Compressed = 2, } +// Safety: CompressionState is #[repr(u8)] with explicit discriminants +unsafe impl bytemuck::Pod for CompressionState {} +unsafe impl bytemuck::Zeroable for CompressionState {} + impl CompressionInfo { + pub fn compressed() -> Self { + Self { + last_claimed_slot: 0, + lamports_per_write: 0, + config_version: 0, + state: CompressionState::Compressed, + _padding: 0, + rent_config: RentConfig { + base_rent: 0, + compression_cost: 0, + lamports_per_byte_per_epoch: 0, + max_funded_epochs: 0, + max_top_up: 0, + }, + } + } + /// Create a new CompressionInfo initialized from a compressible config. /// /// Rent sponsor is always the config's rent_sponsor (not stored per-account). @@ -88,11 +222,12 @@ impl CompressionInfo { /// regardless of who paid for account creation. pub fn new_from_config(cfg: &crate::interface::LightConfig, current_slot: u64) -> Self { Self { - config_version: cfg.version as u16, - lamports_per_write: cfg.write_top_up, last_claimed_slot: current_slot, - rent_config: cfg.rent_config, + lamports_per_write: cfg.write_top_up, + config_version: cfg.version as u16, state: CompressionState::Decompressed, + _padding: 0, + rent_config: cfg.rent_config, } } @@ -100,11 +235,12 @@ impl CompressionInfo { /// Rent will flow to config's rent_sponsor upon compression. pub fn new_decompressed() -> Result { Ok(Self { - config_version: 0, - lamports_per_write: 0, last_claimed_slot: Clock::get()?.slot, - rent_config: RentConfig::default(), + lamports_per_write: 0, + config_version: 0, state: CompressionState::Decompressed, + _padding: 0, + rent_config: RentConfig::default(), }) } @@ -223,8 +359,8 @@ pub trait Space { } impl Space for CompressionInfo { - // 2 (u16 config_version) + 4 (u32 lamports_per_write) + 8 (u64 last_claimed_slot) + size_of::() + 1 (CompressionState) - const INIT_SPACE: usize = 2 + 4 + 8 + core::mem::size_of::() + 1; + // 8 (u64 last_claimed_slot) + 4 (u32 lamports_per_write) + 2 (u16 config_version) + 1 (CompressionState) + 1 padding + 8 (RentConfig) = 24 bytes + const INIT_SPACE: usize = core::mem::size_of::(); } #[cfg(feature = "anchor")] @@ -236,6 +372,10 @@ impl anchor_lang::Space for CompressionInfo { /// Use this constant in account space calculations. pub const OPTION_COMPRESSION_INFO_SPACE: usize = 1 + CompressionInfo::INIT_SPACE; +/// Size of SDK CompressionInfo in bytes (24 bytes). +/// Used for stripping CompressionInfo from Pod data during packing. +pub const COMPRESSION_INFO_SIZE: usize = core::mem::size_of::(); + /// Compressed account data used when decompressing. #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] pub struct CompressedAccountData { @@ -311,6 +451,129 @@ where Ok(Some(0)) } +/// Trait for Pod types with a compression_info field at a fixed byte offset. +/// +/// Unlike `CompressionInfoField` which works with `Option` (Borsh), +/// this trait works with non-optional `CompressionInfo` at a known byte offset. +/// +/// For Pod types, the compression state is indicated by the `state` field: +/// - `state == CompressionState::Uninitialized` means uninitialized +/// - `state == CompressionState::Decompressed` means initialized/decompressed +/// - `state == CompressionState::Compressed` means compressed +/// +/// # Safety +/// Implementors must ensure that: +/// 1. The struct is `#[repr(C)]` for predictable field layout +/// 2. The `COMPRESSION_INFO_OFFSET` matches the actual byte offset of the field +/// 3. The struct implements `bytemuck::Pod` and `bytemuck::Zeroable` +/// 4. The `compression_info` field uses SDK `CompressionInfo` (24 bytes) +pub trait PodCompressionInfoField: bytemuck::Pod { + /// Byte offset of the compression_info field from the start of the struct. + /// Use `core::mem::offset_of!(Self, compression_info)` to compute this at compile time. + const COMPRESSION_INFO_OFFSET: usize; + + /// Strip CompressionInfo bytes from Pod data. + /// + /// Returns a Vec containing: `pod_bytes[..offset] ++ pod_bytes[offset+24..]` + /// + /// This saves 24 bytes per Pod account in instruction data while maintaining + /// hash consistency (the stripped bytes are what get hashed for the Merkle tree). + /// + /// # Arguments + /// * `pod` - Reference to the Pod struct + /// + /// # Returns + /// A Vec with CompressionInfo bytes removed + fn pack_stripped(pod: &Self) -> Vec { + let bytes = bytemuck::bytes_of(pod); + let offset = Self::COMPRESSION_INFO_OFFSET; + let mut result = Vec::with_capacity(bytes.len() - COMPRESSION_INFO_SIZE); + result.extend_from_slice(&bytes[..offset]); + result.extend_from_slice(&bytes[offset + COMPRESSION_INFO_SIZE..]); + result + } + + /// Reconstruct Pod from stripped data by inserting canonical compressed CompressionInfo. + /// + /// The canonical `CompressionInfo::compressed()` bytes are inserted at the offset. + /// This ensures hash consistency: compression hashes full bytes with canonical + /// compressed CompressionInfo, decompression reconstructs the same bytes for verification. + /// + /// After verification, `write_decompressed_info_to_slice_pod` patches to Decompressed state. + /// + /// # Arguments + /// * `stripped_bytes` - Byte slice with CompressionInfo bytes removed + /// + /// # Returns + /// * `Ok(Self)` - Reconstructed Pod with canonical compressed CompressionInfo + /// * `Err` if stripped_bytes length doesn't match expected size + fn unpack_stripped(stripped_bytes: &[u8]) -> Result { + let full_size = core::mem::size_of::(); + let offset = Self::COMPRESSION_INFO_OFFSET; + + if stripped_bytes.len() != full_size - COMPRESSION_INFO_SIZE { + return Err(ProgramError::InvalidAccountData); + } + + // Insert canonical compressed CompressionInfo bytes for hash consistency + let compressed_info = CompressionInfo::compressed(); + let compressed_info_bytes = bytemuck::bytes_of(&compressed_info); + + let mut full_bytes = vec![0u8; full_size]; + full_bytes[..offset].copy_from_slice(&stripped_bytes[..offset]); + full_bytes[offset..offset + COMPRESSION_INFO_SIZE].copy_from_slice(compressed_info_bytes); + full_bytes[offset + COMPRESSION_INFO_SIZE..].copy_from_slice(&stripped_bytes[offset..]); + + Ok(*bytemuck::from_bytes(&full_bytes)) + } + + /// Size of stripped data for this Pod type. + /// + /// # Returns + /// `size_of::() - COMPRESSION_INFO_SIZE` (i.e., full size minus 24 bytes) + fn stripped_size() -> usize { + core::mem::size_of::() - COMPRESSION_INFO_SIZE + } + + /// Write decompressed compression_info directly to a byte slice at the correct offset. + /// + /// This writes the SDK `CompressionInfo` (24 bytes) with `state = Decompressed` + /// and default rent parameters. + /// + /// # Arguments + /// * `data` - Mutable slice of the serialized account data (WITHOUT discriminator prefix) + /// * `current_slot` - Current slot for initializing `last_claimed_slot` + /// + /// # Returns + /// * `Ok(())` on success + /// * `Err` if data slice is too small + fn write_decompressed_info_to_slice_pod( + data: &mut [u8], + current_slot: u64, + ) -> Result<(), ProgramError> { + // Use SDK CompressionInfo (24 bytes) - state=Decompressed indicates initialized + let info = CompressionInfo { + last_claimed_slot: current_slot, + lamports_per_write: 0, + config_version: 1, // 1 = initialized + state: CompressionState::Decompressed, + _padding: 0, + rent_config: RentConfig::default(), + }; + + let info_bytes = bytemuck::bytes_of(&info); + let offset = Self::COMPRESSION_INFO_OFFSET; + let end = offset + core::mem::size_of::(); + + if data.len() < end { + return Err(ProgramError::AccountDataTooSmall); + } + + data[offset..end].copy_from_slice(info_bytes); + Ok(()) + } +} + /// Transfer lamports from one account to another using System Program CPI. /// This is required when transferring from accounts owned by the System Program. /// @@ -325,21 +588,12 @@ fn transfer_lamports_cpi<'a>( system_program: &AccountInfo<'a>, lamports: u64, ) -> Result<(), ProgramError> { - use solana_cpi::invoke; - use solana_instruction::{AccountMeta, Instruction}; - - // System Program ID - const SYSTEM_PROGRAM_ID: [u8; 32] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, - ]; - // System Program Transfer instruction discriminator: 2 (u32 little-endian) let mut instruction_data = vec![2, 0, 0, 0]; instruction_data.extend_from_slice(&lamports.to_le_bytes()); let transfer_instruction = Instruction { - program_id: Pubkey::from(SYSTEM_PROGRAM_ID), + program_id: Pubkey::default(), // System Program ID accounts: vec![ AccountMeta::new(*from.key, true), AccountMeta::new(*to.key, false), @@ -352,3 +606,250 @@ fn transfer_lamports_cpi<'a>( &[from.clone(), to.clone(), system_program.clone()], ) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Test struct to validate PodCompressionInfoField derive macro behavior. + /// This struct mimics what a zero-copy account would look like with SDK CompressionInfo. + #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] + #[repr(C)] + struct TestPodAccount { + pub owner: [u8; 32], + pub counter: u64, + pub compression_info: CompressionInfo, // SDK version (24 bytes) + } + + // Manual impl of PodCompressionInfoField since we can't use the derive macro in unit tests + impl PodCompressionInfoField for TestPodAccount { + const COMPRESSION_INFO_OFFSET: usize = core::mem::offset_of!(TestPodAccount, compression_info); + } + + #[test] + fn test_compression_info_size() { + // Verify CompressionInfo is exactly 24 bytes + assert_eq!( + core::mem::size_of::(), + 24, + "CompressionInfo should be exactly 24 bytes" + ); + } + + #[test] + fn test_compression_state_size() { + // Verify CompressionState is exactly 1 byte + assert_eq!( + core::mem::size_of::(), + 1, + "CompressionState should be exactly 1 byte" + ); + } + + #[test] + fn test_pod_compression_info_offset() { + // Verify offset_of! works correctly + let expected_offset = 32 + 8; // owner (32) + counter (8) + assert_eq!( + TestPodAccount::COMPRESSION_INFO_OFFSET, + expected_offset, + "compression_info offset should be after owner and counter" + ); + } + + #[test] + fn test_write_decompressed_info_to_slice_pod() { + // Create a buffer large enough for TestPodAccount + let account_size = core::mem::size_of::(); + let mut data = vec![0u8; account_size]; + + // Write decompressed info at the correct offset + let current_slot = 12345u64; + TestPodAccount::write_decompressed_info_to_slice_pod(&mut data, current_slot) + .expect("write should succeed"); + + // Verify the compression_info was written correctly + let offset = TestPodAccount::COMPRESSION_INFO_OFFSET; + let info_size = core::mem::size_of::(); + let info_bytes = &data[offset..offset + info_size]; + let info: &CompressionInfo = bytemuck::from_bytes(info_bytes); + + // Verify decompressed state using SDK CompressionInfo fields + assert_eq!(info.config_version, 1, "config_version should be 1 (initialized)"); + assert_eq!(info.last_claimed_slot, current_slot, "last_claimed_slot should match current_slot"); + assert_eq!(info.state, CompressionState::Decompressed, "state should be Decompressed"); + assert_eq!(info.lamports_per_write, 0, "lamports_per_write should be 0"); + } + + #[test] + fn test_write_decompressed_info_to_slice_pod_too_small() { + // Buffer too small to hold the compression_info + let mut data = vec![0u8; TestPodAccount::COMPRESSION_INFO_OFFSET - 1]; + + let result = TestPodAccount::write_decompressed_info_to_slice_pod(&mut data, 0); + assert!(result.is_err(), "write should fail for buffer too small"); + } + + #[test] + fn test_pack_stripped() { + // Create a test account with known values + let account = TestPodAccount { + owner: [1u8; 32], + counter: 42, + compression_info: CompressionInfo { + last_claimed_slot: 100, + lamports_per_write: 200, + config_version: 1, + state: CompressionState::Compressed, + _padding: 0, + rent_config: RentConfig::default(), + }, + }; + + let stripped = TestPodAccount::pack_stripped(&account); + + // Stripped size should be full size minus COMPRESSION_INFO_SIZE (24 bytes) + let full_size = core::mem::size_of::(); + assert_eq!( + stripped.len(), + full_size - COMPRESSION_INFO_SIZE, + "stripped size should be {} bytes (full {} - compression_info {})", + full_size - COMPRESSION_INFO_SIZE, + full_size, + COMPRESSION_INFO_SIZE + ); + + // Verify owner bytes are preserved at the start + assert_eq!(&stripped[..32], &[1u8; 32], "owner should be preserved"); + + // Verify counter bytes are preserved after owner + let counter_bytes = &stripped[32..40]; + assert_eq!( + u64::from_le_bytes(counter_bytes.try_into().unwrap()), + 42, + "counter should be preserved" + ); + + // Verify stripped_size() matches + assert_eq!( + TestPodAccount::stripped_size(), + stripped.len(), + "stripped_size() should match actual stripped length" + ); + } + + #[test] + fn test_unpack_stripped() { + // Create a test account + let original = TestPodAccount { + owner: [2u8; 32], + counter: 123, + compression_info: CompressionInfo { + last_claimed_slot: 500, + lamports_per_write: 300, + config_version: 2, + state: CompressionState::Compressed, + _padding: 0, + rent_config: RentConfig::default(), + }, + }; + + // Strip it + let stripped = TestPodAccount::pack_stripped(&original); + + // Unpack it + let reconstructed = TestPodAccount::unpack_stripped(&stripped) + .expect("unpack_stripped should succeed"); + + // Verify non-compression_info fields are preserved + assert_eq!(reconstructed.owner, original.owner, "owner should match"); + assert_eq!(reconstructed.counter, original.counter, "counter should match"); + + // Verify compression_info has canonical compressed values (for hash consistency) + assert_eq!( + reconstructed.compression_info.last_claimed_slot, 0, + "compression_info.last_claimed_slot should be 0 (canonical compressed)" + ); + assert_eq!( + reconstructed.compression_info.state, + CompressionState::Compressed, + "compression state should be Compressed (canonical compressed)" + ); + } + + #[test] + fn test_unpack_stripped_wrong_size() { + // Try to unpack with wrong size + let too_short = vec![0u8; TestPodAccount::stripped_size() - 1]; + let result = TestPodAccount::unpack_stripped(&too_short); + assert!(result.is_err(), "unpack should fail for wrong size"); + + let too_long = vec![0u8; TestPodAccount::stripped_size() + 1]; + let result = TestPodAccount::unpack_stripped(&too_long); + assert!(result.is_err(), "unpack should fail for wrong size"); + } + + #[test] + fn test_stripped_roundtrip() { + // Create account, strip, unpack, verify stripping produces same bytes + let original = TestPodAccount { + owner: [3u8; 32], + counter: 999, + compression_info: CompressionInfo { + last_claimed_slot: 1000, + lamports_per_write: 400, + config_version: 3, + state: CompressionState::Compressed, + _padding: 0, + rent_config: RentConfig::default(), + }, + }; + + // Strip (removes CompressionInfo bytes) + let stripped = TestPodAccount::pack_stripped(&original); + + // Unpack (reconstruct with canonical compressed CompressionInfo) + let reconstructed = TestPodAccount::unpack_stripped(&stripped) + .expect("unpack should succeed"); + + // Verify data fields are intact + assert_eq!(reconstructed.owner, original.owner); + assert_eq!(reconstructed.counter, original.counter); + + // Now strip the reconstructed version and verify it matches + let re_stripped = TestPodAccount::pack_stripped(&reconstructed); + assert_eq!( + stripped, re_stripped, + "re-stripping reconstructed account should produce same bytes" + ); + } + + #[test] + fn test_hash_consistency() { + // Create account with canonical compressed CompressionInfo (what compression does) + let with_canonical = TestPodAccount { + owner: [4u8; 32], + counter: 42, + compression_info: CompressionInfo::compressed(), + }; + + // Get full bytes (what compression would hash) + let compression_bytes = bytemuck::bytes_of(&with_canonical); + + // Strip and transmit (what goes over the wire) + let stripped = TestPodAccount::pack_stripped(&with_canonical); + + // Reconstruct (what decompression does) + let reconstructed = TestPodAccount::unpack_stripped(&stripped) + .expect("unpack should succeed"); + + // Get reconstructed full bytes (what decompression would hash) + let decompression_bytes = bytemuck::bytes_of(&reconstructed); + + // Bytes must match for Merkle tree hash verification to work + assert_eq!( + compression_bytes, decompression_bytes, + "compression and decompression bytes must be identical for hash consistency" + ); + } +} diff --git a/sdk-libs/sdk/src/interface/decompress_idempotent.rs b/sdk-libs/sdk/src/interface/decompress_idempotent.rs index d4512bfacb..5dac969332 100644 --- a/sdk-libs/sdk/src/interface/decompress_idempotent.rs +++ b/sdk-libs/sdk/src/interface/decompress_idempotent.rs @@ -1,6 +1,12 @@ #![allow(clippy::all)] // TODO: Remove. -use light_compressed_account::address::derive_address; +use light_compressed_account::{ + address::derive_address, + compressed_account::PackedMerkleContext, + instruction_data::with_account_info::{CompressedAccountInfo, InAccountInfo, OutAccountInfo}, +}; +use light_hasher::{Hasher, Sha256}; +use light_program_profiler::profile; use light_sdk_types::instruction::account_meta::{ CompressedAccountMeta, CompressedAccountMetaNoLamportsNoAddress, }; @@ -9,16 +15,39 @@ use solana_cpi::invoke_signed; use solana_msg::msg; use solana_pubkey::Pubkey; use solana_system_interface::instruction as system_instruction; -use solana_sysvar::{rent::Rent, Sysvar}; +use solana_sysvar::rent::Rent; use crate::{ - account::sha::LightAccount, - compressible::compression_info::{CompressionInfo, HasCompressionInfo}, + account::LightAccountInner, + compressible::compression_info::{ + CompressionInfo, CompressionInfoField, HasCompressionInfo, PodCompressionInfoField, + }, cpi::v2::CpiAccounts, error::LightSdkError, AnchorDeserialize, AnchorSerialize, LightDiscriminator, }; +/// Compute the data hash for compressed account verification. +/// +/// This is the canonical way to hash account data for Light Protocol: +/// 1. Hash the raw data bytes (WITHOUT discriminator prefix) +/// 2. Zero the first byte per protocol convention +/// +/// Both Borsh and Pod decompression paths must use this same logic +/// to ensure hash consistency. +/// +/// # Arguments +/// * `data_bytes` - Raw account data bytes (discriminator NOT included) +/// +/// # Returns +/// * 32-byte hash with first byte zeroed +#[inline] +pub fn compute_data_hash(data_bytes: &[u8]) -> Result<[u8; 32], LightSdkError> { + let mut hash = Sha256::hash(data_bytes).map_err(LightSdkError::from)?; + hash[0] = 0; // Zero first byte per protocol convention + Ok(hash) +} + /// Convert a `CompressedAccountMetaNoLamportsNoAddress` to a /// `CompressedAccountMeta` by deriving the compressed address from the solana /// account's pubkey. @@ -43,24 +72,91 @@ pub fn into_compressed_meta_with_address<'info>( meta_with_address } -// TODO: consider folding into main fn. -/// Helper to invoke create_account on heap. +/// Cold path: Account already has lamports (e.g., attacker donation). +/// Uses Assign + Allocate + Transfer instead of CreateAccount which would fail. +#[cold] +fn create_pda_account_with_lamports<'info>( + rent_sponsor: &AccountInfo<'info>, + solana_account: &AccountInfo<'info>, + lamports: u64, + space: u64, + owner: &Pubkey, + seeds: &[&[u8]], + system_program: &AccountInfo<'info>, +) -> Result<(), LightSdkError> { + let current_lamports = solana_account.lamports(); + + // Assign owner + let assign_ix = system_instruction::assign(solana_account.key, owner); + invoke_signed( + &assign_ix, + &[solana_account.clone(), system_program.clone()], + &[seeds], + ) + .map_err(LightSdkError::ProgramError)?; + + // Allocate space + let allocate_ix = system_instruction::allocate(solana_account.key, space); + invoke_signed( + &allocate_ix, + &[solana_account.clone(), system_program.clone()], + &[seeds], + ) + .map_err(LightSdkError::ProgramError)?; + + // Transfer remaining lamports for rent-exemption if needed + if lamports > current_lamports { + let transfer_ix = system_instruction::transfer( + rent_sponsor.key, + solana_account.key, + lamports - current_lamports, + ); + invoke_signed( + &transfer_ix, + &[ + rent_sponsor.clone(), + solana_account.clone(), + system_program.clone(), + ], + &[], + ) + .map_err(LightSdkError::ProgramError)?; + } + + Ok(()) +} + +/// Creates a PDA account, handling the case where the account already has lamports. #[inline(never)] -fn invoke_create_account_with_heap<'info>( +fn create_pda_account<'info>( rent_sponsor: &AccountInfo<'info>, solana_account: &AccountInfo<'info>, - rent_minimum_balance: u64, + lamports: u64, space: u64, - program_id: &Pubkey, + owner: &Pubkey, seeds: &[&[u8]], system_program: &AccountInfo<'info>, ) -> Result<(), LightSdkError> { + // Cold path: account already has lamports (e.g., attacker donation) + if solana_account.lamports() > 0 { + return create_pda_account_with_lamports( + rent_sponsor, + solana_account, + lamports, + space, + owner, + seeds, + system_program, + ); + } + + // Normal path: CreateAccount let create_account_ix = system_instruction::create_account( rent_sponsor.key, solana_account.key, - rent_minimum_balance, + lamports, space, - program_id, + owner, ); invoke_signed( @@ -72,21 +168,23 @@ fn invoke_create_account_with_heap<'info>( ], &[seeds], ) - .map_err(|e| LightSdkError::ProgramError(e)) + .map_err(LightSdkError::ProgramError) } /// Helper function to decompress a compressed account into a PDA /// idempotently with seeds. #[inline(never)] -#[cfg(feature = "v2")] +#[profile] pub fn prepare_account_for_decompression_idempotent<'a, 'info, T>( program_id: &Pubkey, - data: T, + mut account: T, compressed_meta: CompressedAccountMeta, solana_account: &AccountInfo<'info>, rent_sponsor: &AccountInfo<'info>, cpi_accounts: &CpiAccounts<'a, 'info>, signer_seeds: &[&[u8]], + rent: &Rent, + current_slot: u64, ) -> Result< Option, LightSdkError, @@ -99,26 +197,30 @@ where + AnchorSerialize + AnchorDeserialize + HasCompressionInfo + + CompressionInfoField + 'info, { + // Check if account is already initialized by examining discriminator if !solana_account.data_is_empty() { - msg!("Account already initialized, skipping"); - return Ok(None); + let data = solana_account.try_borrow_data()?; + // If discriminator is NOT zeroed, account is already initialized - skip + if light_account_checks::checks::check_data_is_zeroed::<8>(&data).is_err() { + msg!("Account already initialized, skipping"); + return Ok(None); + } + // Discriminator is zeroed but data exists - unexpected state, let create_pda fail } - let rent = Rent::get().map_err(|err| { - msg!("Failed to get rent: {:?}", err); - LightSdkError::Borsh - })?; - - let light_account = LightAccount::::new_close(program_id, &compressed_meta, data)?; + *account.compression_info_mut_opt() = Some(CompressionInfo::compressed()); + let (light_account, data) = + LightAccountInner::::new_mut_inner(program_id, &compressed_meta, account)?; // Account space needs to include discriminator + serialized data // T::size() already includes the full Option footprint let discriminator_len = T::LIGHT_DISCRIMINATOR.len(); - let space = discriminator_len + T::size(&light_account.account)?; + let space = discriminator_len + data.len(); let rent_minimum_balance = rent.minimum_balance(space); - invoke_create_account_with_heap( + create_pda_account( rent_sponsor, solana_account, rent_minimum_balance, @@ -128,18 +230,141 @@ where cpi_accounts.system_program()?, )?; - let mut decompressed_pda = light_account.account.clone(); - *decompressed_pda.compression_info_mut_opt() = Some(CompressionInfo::new_decompressed()?); - + // Write discriminator + already-serialized data, then patch compression_info in place let mut account_data = solana_account.try_borrow_mut_data()?; let discriminator_len = T::LIGHT_DISCRIMINATOR.len(); account_data[..discriminator_len].copy_from_slice(&T::LIGHT_DISCRIMINATOR); - decompressed_pda - .serialize(&mut &mut account_data[discriminator_len..]) + account_data[discriminator_len..space].copy_from_slice(&data); + + // Patch compression_info to decompressed state at the correct offset + T::write_decompressed_info_to_slice(&mut account_data[discriminator_len..], current_slot) .map_err(|err| { - msg!("Failed to serialize decompressed PDA: {:?}", err); + msg!("Failed to write decompressed compression_info: {:?}", err); LightSdkError::Borsh })?; Ok(Some(light_account.to_account_info()?)) } + +/// Helper function to decompress a compressed account into a PDA +/// idempotently with seeds. Optimized for Pod (zero-copy) accounts. +/// +/// # Key Differences from Borsh Version +/// +/// - Uses `std::mem::size_of::()` for static size calculation +/// - Uses `bytemuck::bytes_of()` instead of Borsh serialization +/// - Patches CompressionInfo at fixed byte offset (no Option discriminant) +/// - More efficient for accounts with fixed-size layout +/// +/// # Type Requirements +/// +/// - `T` must implement `bytemuck::Pod` and `bytemuck::Zeroable` +/// - `T` must be `#[repr(C)]` for predictable field layout +/// - `T` must implement `PodCompressionInfoField` for compression state management +/// +/// # Hash Consistency +/// +/// Pod accounts use their own hashing path independent of Borsh accounts. +/// The hash is computed from `bytemuck::bytes_of(&account)`, which gives +/// the raw memory representation. This is consistent as long as: +/// - The same Pod type is used for compression and decompression +/// - No mixing between Pod and Borsh code paths for the same account type +#[inline(never)] +#[profile] +pub fn prepare_account_for_decompression_idempotent_pod<'a, 'info, T>( + _program_id: &Pubkey, + account: T, + compressed_meta: CompressedAccountMeta, + solana_account: &AccountInfo<'info>, + rent_sponsor: &AccountInfo<'info>, + cpi_accounts: &CpiAccounts<'a, 'info>, + signer_seeds: &[&[u8]], + rent: &Rent, + current_slot: u64, +) -> Result, LightSdkError> +where + T: bytemuck::Pod + + bytemuck::Zeroable + + Copy + + LightDiscriminator + + PodCompressionInfoField + + Default + + 'info, +{ + // Check if account is already initialized by examining discriminator + if !solana_account.data_is_empty() { + let data = solana_account.try_borrow_data()?; + // If discriminator is NOT zeroed, account is already initialized - skip + if light_account_checks::checks::check_data_is_zeroed::<8>(&data).is_err() { + msg!("Account already initialized, skipping"); + return Ok(None); + } + // Discriminator is zeroed but data exists - unexpected state, let create_pda fail + } + + // Hash the FULL bytes for input verification (matches what's in Merkle tree) + // During compression, we hashed full bytes with canonical CompressionInfo::compressed(). + // The account parameter was reconstructed via unpack_stripped, which inserted the + // same canonical compressed bytes, so hashing full bytes will match. + let full_bytes = bytemuck::bytes_of(&account); + let input_data_hash = compute_data_hash(full_bytes)?; + + // Build input account info + let tree_info = compressed_meta.tree_info; + let input_account_info = InAccountInfo { + data_hash: input_data_hash, + lamports: 0, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_info.merkle_tree_pubkey_index, + queue_pubkey_index: tree_info.queue_pubkey_index, + leaf_index: tree_info.leaf_index, + prove_by_index: tree_info.prove_by_index, + }, + root_index: tree_info.root_index, + discriminator: T::LIGHT_DISCRIMINATOR, + }; + + // Static size calculation - more efficient than dynamic + let discriminator_len = T::LIGHT_DISCRIMINATOR.len(); + let space = discriminator_len + core::mem::size_of::(); + let rent_minimum_balance = rent.minimum_balance(space); + + create_pda_account( + rent_sponsor, + solana_account, + rent_minimum_balance, + space as u64, + &cpi_accounts.self_program_id(), + signer_seeds, + cpi_accounts.system_program()?, + )?; + + // Write discriminator + raw Pod bytes (full bytes, not stripped) + // The account was reconstructed from stripped bytes with zeros at CompressionInfo offset + let full_bytes = bytemuck::bytes_of(&account); + let mut account_data = solana_account.try_borrow_mut_data()?; + account_data[..discriminator_len].copy_from_slice(&T::LIGHT_DISCRIMINATOR); + account_data[discriminator_len..space].copy_from_slice(full_bytes); + + // Patch compression_info to decompressed state at fixed offset + T::write_decompressed_info_to_slice_pod(&mut account_data[discriminator_len..], current_slot) + .map_err(|err| { + msg!("Failed to write decompressed compression_info: {:?}", err); + LightSdkError::Borsh + })?; + + // Build output account info + let output_account_info = OutAccountInfo { + lamports: 0, + output_merkle_tree_index: compressed_meta.output_state_tree_index, + discriminator: T::LIGHT_DISCRIMINATOR, + data: Vec::new(), + data_hash: [0u8; 32], + }; + + Ok(Some(CompressedAccountInfo { + address: Some(compressed_meta.address), + input: Some(input_account_info), + output: Some(output_account_info), + })) +} diff --git a/sdk-libs/sdk/src/interface/decompress_runtime.rs b/sdk-libs/sdk/src/interface/decompress_runtime.rs index 3e4a3859da..75093ff1e3 100644 --- a/sdk-libs/sdk/src/interface/decompress_runtime.rs +++ b/sdk-libs/sdk/src/interface/decompress_runtime.rs @@ -1,4 +1,10 @@ //! Traits and processor for decompress_accounts_idempotent instruction. +//! +//! This module provides: +//! - `DecompressCtx` - A context struct holding all data needed for decompression +//! - `DecompressibleAccount` - A trait for account variants that can be decompressed +//! - `process_decompress_accounts_idempotent` - The main processor function + use light_compressed_account::instruction_data::{ cpi_context::CompressedCpiContext, with_account_info::{CompressedAccountInfo, InstructionDataInvokeCpiWithAccountInfo}, @@ -8,14 +14,66 @@ use light_sdk_types::{ instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, CpiSigner, }; use solana_account_info::AccountInfo; -use solana_msg::msg; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use crate::{ - cpi::{v2::CpiAccounts, InvokeLightSystemProgram}, - AnchorDeserialize, AnchorSerialize, LightDiscriminator, -}; +use crate::cpi::{v2::CpiAccounts, InvokeLightSystemProgram}; + +// ============================================================================= +// NEW SIMPLIFIED ARCHITECTURE +// ============================================================================= + +/// Context struct for decompression operations. +/// +/// This replaces the complex `DecompressContext` trait with a simple struct +/// containing all the data needed for decompression. +pub struct DecompressCtx<'a, 'info> { + /// The program ID for PDA derivation + pub program_id: &'a Pubkey, + /// The address space for compressed account derivation + pub address_space: Pubkey, + /// CPI accounts for invoking the Light system program + pub cpi_accounts: &'a CpiAccounts<'a, 'info>, + /// Remaining accounts for resolving packed indices + pub remaining_accounts: &'a [AccountInfo<'info>], + /// Account to sponsor rent for decompressed accounts + pub rent_sponsor: &'a AccountInfo<'info>, + /// Rent sysvar for calculating minimum balance + pub rent: &'a solana_sysvar::rent::Rent, + /// Current slot for compression info + pub current_slot: u64, +} + +/// Trait for account variants that can be decompressed. +/// +/// Each packed account variant implements this trait to handle its own +/// decompression logic, eliminating complex match statements in the processor. +pub trait DecompressibleAccount { + /// Returns true if this is a token account variant. + fn is_token(&self) -> bool; + + /// Prepare this account for decompression. + /// + /// This method: + /// 1. Resolves any packed indices to actual Pubkeys + /// 2. Unpacks the data + /// 3. Derives and verifies the PDA + /// 4. Creates the Solana account and writes data + /// + /// Returns `Some(CompressedAccountInfo)` if decompression was performed, + /// or `None` if the account was already decompressed (idempotent). + fn prepare<'a, 'info>( + self, + ctx: &DecompressCtx<'a, 'info>, + solana_account: &AccountInfo<'info>, + meta: &CompressedAccountMetaNoLamportsNoAddress, + index: usize, + ) -> Result, ProgramError>; +} + +// ============================================================================= +// LEGACY TRAITS (kept for backward compatibility during transition) +// ============================================================================= /// Trait for account variants that can be checked for token or PDA type. pub trait HasTokenVariant { @@ -49,9 +107,6 @@ pub trait DecompressContext<'info> { /// Compressed account metadata type (standardized) type CompressedMeta: Clone; - /// Seed parameters type containing data.* field values from instruction data - type SeedParams; - // Account accessors fn fee_payer(&self) -> &AccountInfo<'info>; fn config(&self) -> &AccountInfo<'info>; @@ -70,7 +125,8 @@ pub trait DecompressContext<'info> { address_space: Pubkey, compressed_accounts: Vec, solana_accounts: &[AccountInfo<'info>], - seed_params: Option<&Self::SeedParams>, + rent: &solana_sysvar::rent::Rent, + current_slot: u64, ) -> Result<( Vec, Vec<(Self::PackedTokenData, Self::CompressedMeta)> @@ -123,84 +179,6 @@ pub fn check_account_types(compressed_accounts: &[T]) -> (bo (has_tokens, has_pdas) } -/// Handler for unpacking and preparing a single PDA variant for decompression. -#[inline(never)] -#[allow(clippy::too_many_arguments)] -pub fn handle_packed_pda_variant<'a, 'b, 'info, T, P, A, S>( - accounts_rent_sponsor: &AccountInfo<'info>, - cpi_accounts: &CpiAccounts<'b, 'info>, - address_space: Pubkey, - solana_account: &AccountInfo<'info>, - index: usize, - packed: &P, - meta: &CompressedAccountMetaNoLamportsNoAddress, - post_system_accounts: &[AccountInfo<'info>], - compressed_pda_infos: &mut Vec, - program_id: &Pubkey, - seed_accounts: &A, - seed_params: Option<&S>, -) -> Result<(), ProgramError> -where - T: PdaSeedDerivation - + Clone - + crate::account::Size - + LightDiscriminator - + Default - + AnchorSerialize - + AnchorDeserialize - + crate::interface::HasCompressionInfo - + 'info, - P: crate::interface::Unpack, - S: Default, -{ - let data: T = P::unpack(packed, post_system_accounts)?; - - let (seeds_vec, derived_pda) = if let Some(params) = seed_params { - data.derive_pda_seeds_with_accounts(program_id, seed_accounts, params)? - } else { - let default_params = S::default(); - data.derive_pda_seeds_with_accounts(program_id, seed_accounts, &default_params)? - }; - if derived_pda != *solana_account.key { - msg!( - "Derived PDA does not match account at index {}: expected {:?}, got {:?}, seeds: {:?}", - index, - solana_account.key, - derived_pda, - seeds_vec - ); - return Err(ProgramError::from( - crate::error::LightSdkError::ConstraintViolation, - )); - } - - let compressed_infos = { - // Use fixed-size array to avoid heap allocation (MAX_SEEDS = 16) - const MAX_SEEDS: usize = 16; - let mut seed_refs: [&[u8]; MAX_SEEDS] = [&[]; MAX_SEEDS]; - let len = seeds_vec.len().min(MAX_SEEDS); - for i in 0..len { - seed_refs[i] = seeds_vec[i].as_slice(); - } - crate::interface::decompress_idempotent::prepare_account_for_decompression_idempotent::( - program_id, - data, - crate::interface::decompress_idempotent::into_compressed_meta_with_address( - meta, - solana_account, - address_space, - program_id, - ), - solana_account, - accounts_rent_sponsor, - cpi_accounts, - &seed_refs[..len], - )? - }; - compressed_pda_infos.extend(compressed_infos); - Ok(()) -} - /// Processor for decompress_accounts_idempotent. /// /// CPI context batching rules: @@ -216,7 +194,8 @@ pub fn process_decompress_accounts_idempotent<'info, Ctx>( system_accounts_offset: u8, cpi_signer: CpiSigner, program_id: &Pubkey, - seed_params: Option<&Ctx::SeedParams>, + rent: &solana_sysvar::rent::Rent, + current_slot: u64, ) -> Result<(), ProgramError> where Ctx: DecompressContext<'info>, @@ -265,7 +244,8 @@ where address_space, compressed_accounts, solana_accounts, - seed_params, + rent, + current_slot, )?; let has_pdas = !compressed_pda_infos.is_empty(); diff --git a/sdk-libs/sdk/src/interface/mod.rs b/sdk-libs/sdk/src/interface/mod.rs index f6111f1c73..8a447a88d0 100644 --- a/sdk-libs/sdk/src/interface/mod.rs +++ b/sdk-libs/sdk/src/interface/mod.rs @@ -20,13 +20,16 @@ pub mod decompress_runtime; #[cfg(feature = "v2")] pub use close::close; #[cfg(feature = "v2")] -pub use compress_account::prepare_account_for_compression; +pub use compress_account::{prepare_account_for_compression, prepare_account_for_compression_pod}; #[cfg(feature = "v2")] -pub use compress_account_on_init::prepare_compressed_account_on_init; +pub use compress_account_on_init::{ + prepare_compressed_account_on_init, prepare_compressed_account_on_init_pod, +}; #[cfg(feature = "v2")] pub use compress_runtime::{process_compress_pda_accounts_idempotent, CompressContext}; pub use compression_info::{ - CompressAs, CompressedInitSpace, CompressionInfo, HasCompressionInfo, Pack, Space, Unpack, + CompressAs, CompressedInitSpace, CompressionInfo, CompressionInfoField, CompressionState, + HasCompressionInfo, Pack, PodCompressionInfoField, Space, Unpack, COMPRESSION_INFO_SIZE, OPTION_COMPRESSION_INFO_SPACE, }; pub use config::{ @@ -36,11 +39,12 @@ pub use config::{ }; #[cfg(feature = "v2")] pub use decompress_idempotent::{ - into_compressed_meta_with_address, prepare_account_for_decompression_idempotent, + compute_data_hash, into_compressed_meta_with_address, + prepare_account_for_decompression_idempotent, prepare_account_for_decompression_idempotent_pod, }; #[cfg(all(feature = "v2", feature = "cpi-context"))] pub use decompress_runtime::{ - check_account_types, handle_packed_pda_variant, process_decompress_accounts_idempotent, - DecompressContext, HasTokenVariant, PdaSeedDerivation, TokenSeedProvider, + check_account_types, process_decompress_accounts_idempotent, DecompressContext, DecompressCtx, + DecompressibleAccount, HasTokenVariant, PdaSeedDerivation, TokenSeedProvider, }; pub use light_compressible::{rent, CreateAccountsProof}; diff --git a/sdk-libs/token-sdk/src/compressible/compress_runtime.rs b/sdk-libs/token-sdk/src/compressible/compress_runtime.rs new file mode 100644 index 0000000000..79763dec56 --- /dev/null +++ b/sdk-libs/token-sdk/src/compressible/compress_runtime.rs @@ -0,0 +1,55 @@ +//! Runtime helpers for compressing PDAs to Light Protocol. + +use light_compressed_account::instruction_data::{ + data::NewAddressParamsAssignedPacked, with_account_info::CompressedAccountInfo, +}; +use light_sdk::{ + cpi::{ + v2::{CpiAccounts, LightSystemProgramCpi}, + InvokeLightSystemProgram, LightCpiInstruction, + }, + instruction::ValidityProof, +}; +use light_sdk_types::CpiSigner; +use solana_program_error::ProgramError; + +use crate::error::LightTokenError; + +/// Write PDAs to CPI context for chaining with mint operations. +/// +/// Use this when PDAs need to be written to CPI context first, which will be +/// consumed by subsequent mint operations (e.g., CreateMintsCpi). +/// +/// # Arguments +/// * `cpi_signer` - CPI signer for the invoking program +/// * `proof` - Validity proof for the compression operation +/// * `new_addresses` - New address parameters for each PDA +/// * `compressed_infos` - Compressed account info for each PDA +/// * `cpi_accounts` - CPI accounts with CPI context enabled +pub fn invoke_write_pdas_to_cpi_context<'info>( + cpi_signer: CpiSigner, + proof: ValidityProof, + new_addresses: &[NewAddressParamsAssignedPacked], + compressed_infos: &[CompressedAccountInfo], + cpi_accounts: &CpiAccounts<'_, 'info>, +) -> Result<(), ProgramError> { + let cpi_context_account = cpi_accounts + .cpi_context() + .map_err(|_| LightTokenError::MissingCpiContext)?; + let cpi_context_accounts = light_sdk_types::cpi_context_write::CpiContextWriteAccounts { + fee_payer: cpi_accounts.fee_payer(), + authority: cpi_accounts + .authority() + .map_err(|_| LightTokenError::MissingCpiAuthority)?, + cpi_context: cpi_context_account, + cpi_signer, + }; + + LightSystemProgramCpi::new_cpi(cpi_signer, proof) + .with_new_addresses(new_addresses) + .with_account_infos(compressed_infos) + .write_to_cpi_context_first() + .invoke_write_to_cpi_context_first(cpi_context_accounts)?; + + Ok(()) +} diff --git a/sdk-libs/token-sdk/src/compressible/decompress_runtime.rs b/sdk-libs/token-sdk/src/compressible/decompress_runtime.rs index f4076029b0..2fcd187f38 100644 --- a/sdk-libs/token-sdk/src/compressible/decompress_runtime.rs +++ b/sdk-libs/token-sdk/src/compressible/decompress_runtime.rs @@ -199,78 +199,81 @@ where packed_accounts, ) .map_err(ProgramError::from)?; + // TODO: extract into function and reuse existing system accounts builder. + { + // Build account infos for CPI. Must include all accounts needed by the transfer2 instruction: + // - System accounts (light_system_program, registered_program_pda, etc.) + // - Fee payer, ctoken accounts + // - CPI context (if present) + // - All packed accounts (post_system_accounts) + let mut all_account_infos: Vec> = + Vec::with_capacity(12 + post_system_accounts.len()); + all_account_infos.push(fee_payer.clone()); + all_account_infos.push(token_cpi_authority.clone()); + all_account_infos.push(token_program.clone()); + all_account_infos.push(token_rent_sponsor.clone()); + all_account_infos.push(config.clone()); - // Build account infos for CPI. Must include all accounts needed by the transfer2 instruction: - // - System accounts (light_system_program, registered_program_pda, etc.) - // - Fee payer, ctoken accounts - // - CPI context (if present) - // - All packed accounts (post_system_accounts) - let mut all_account_infos: Vec> = - Vec::with_capacity(12 + post_system_accounts.len()); - all_account_infos.push(fee_payer.clone()); - all_account_infos.push(token_cpi_authority.clone()); - all_account_infos.push(token_program.clone()); - all_account_infos.push(token_rent_sponsor.clone()); - all_account_infos.push(config.clone()); + // Add required system accounts for transfer2 instruction + // Light system program is at index 0 in the cpi_accounts slice + all_account_infos.push( + cpi_accounts + .account_infos() + .first() + .ok_or(ProgramError::NotEnoughAccountKeys)? + .clone(), + ); + all_account_infos.push( + cpi_accounts + .registered_program_pda() + .map_err(|_| ProgramError::InvalidAccountData)? + .clone(), + ); + all_account_infos.push( + cpi_accounts + .account_compression_authority() + .map_err(|_| ProgramError::InvalidAccountData)? + .clone(), + ); + all_account_infos.push( + cpi_accounts + .account_compression_program() + .map_err(|_| ProgramError::InvalidAccountData)? + .clone(), + ); + all_account_infos.push( + cpi_accounts + .system_program() + .map_err(|_| ProgramError::InvalidAccountData)? + .clone(), + ); - // Add required system accounts for transfer2 instruction - // Light system program is at index 0 in the cpi_accounts slice - all_account_infos.push( - cpi_accounts - .account_infos() - .first() - .ok_or(ProgramError::NotEnoughAccountKeys)? - .clone(), - ); - all_account_infos.push( - cpi_accounts - .registered_program_pda() - .map_err(|_| ProgramError::InvalidAccountData)? - .clone(), - ); - all_account_infos.push( - cpi_accounts - .account_compression_authority() - .map_err(|_| ProgramError::InvalidAccountData)? - .clone(), - ); - all_account_infos.push( - cpi_accounts - .account_compression_program() - .map_err(|_| ProgramError::InvalidAccountData)? - .clone(), - ); - all_account_infos.push( - cpi_accounts - .system_program() - .map_err(|_| ProgramError::InvalidAccountData)? - .clone(), - ); - - // Add CPI context if present - if let Ok(cpi_context) = cpi_accounts.cpi_context() { - all_account_infos.push(cpi_context.clone()); - } + // Add CPI context if present + if let Ok(cpi_context) = cpi_accounts.cpi_context() { + all_account_infos.push(cpi_context.clone()); + } - all_account_infos.extend_from_slice(post_system_accounts); + all_account_infos.extend_from_slice(post_system_accounts); - // Only include signer seeds for program-owned tokens - if token_signers_seed_groups.is_empty() { - // All tokens were ATAs - no program signing needed - solana_cpi::invoke(&ctoken_ix, all_account_infos.as_slice())?; - } else { - let signer_seed_refs: Vec> = token_signers_seed_groups - .iter() - .map(|group| group.iter().map(|s| s.as_slice()).collect()) - .collect(); - let signer_seed_slices: Vec<&[&[u8]]> = - signer_seed_refs.iter().map(|g| g.as_slice()).collect(); + // Only include signer seeds for program-owned tokens + if token_signers_seed_groups.is_empty() { + // All tokens were ATAs - no program signing needed + solana_cpi::invoke(&ctoken_ix, all_account_infos.as_slice())?; + } else { + // TODO: try to reduce allocs. we already alloc before. + let signer_seed_refs: Vec> = token_signers_seed_groups + .iter() + .map(|group| group.iter().map(|s| s.as_slice()).collect()) + .collect(); + let signer_seed_slices: Vec<&[&[u8]]> = + signer_seed_refs.iter().map(|g| g.as_slice()).collect(); - solana_cpi::invoke_signed( - &ctoken_ix, - all_account_infos.as_slice(), - signer_seed_slices.as_slice(), - )?; + solana_cpi::invoke_signed( + &ctoken_ix, + all_account_infos.as_slice(), + signer_seed_slices.as_slice(), + )?; + } } Ok(()) diff --git a/sdk-libs/token-sdk/src/compressible/mint_runtime.rs b/sdk-libs/token-sdk/src/compressible/mint_runtime.rs new file mode 100644 index 0000000000..3a771bf736 --- /dev/null +++ b/sdk-libs/token-sdk/src/compressible/mint_runtime.rs @@ -0,0 +1,105 @@ +//! Runtime helpers for compressed mint creation. +//! +//! These functions consolidate the CPI setup logic used by `#[derive(LightAccounts)]` +//! macro for mint creation, reducing macro complexity and SDK coupling. + +use light_sdk::cpi::v2::CpiAccounts; +use solana_account_info::AccountInfo; +use solana_program_error::ProgramError; + +use crate::error::LightTokenError; +use crate::instruction::{CreateMintsCpi, CreateMintsParams, SystemAccountInfos}; + +/// Infrastructure accounts needed for mint creation CPI. +/// +/// These accounts are passed from the user's Accounts struct. +pub struct CreateMintsInfraAccounts<'info> { + /// Fee payer for the transaction. + pub fee_payer: AccountInfo<'info>, + /// CompressibleConfig account for the light-token program. + pub compressible_config: AccountInfo<'info>, + /// Rent sponsor PDA. + pub rent_sponsor: AccountInfo<'info>, + /// CPI authority PDA for signing. + pub cpi_authority: AccountInfo<'info>, +} + +/// Invoke CreateMintsCpi to create and decompress compressed mints. +/// +/// This function handles: +/// - Extracting tree accounts from CpiAccounts +/// - Building the SystemAccountInfos +/// - Constructing and invoking CreateMintsCpi +/// +/// # Arguments +/// * `mint_seed_accounts` - AccountInfos for mint signers (one per mint) +/// * `mint_accounts` - AccountInfos for mint PDAs (one per mint) +/// * `params` - CreateMintsParams with mint params and configuration +/// * `infra` - Infrastructure accounts from the Accounts struct +/// * `cpi_accounts` - CpiAccounts for accessing system accounts +#[inline(never)] +pub fn invoke_create_mints<'a, 'info>( + mint_seed_accounts: &'a [AccountInfo<'info>], + mint_accounts: &'a [AccountInfo<'info>], + params: CreateMintsParams<'a>, + infra: CreateMintsInfraAccounts<'info>, + cpi_accounts: &CpiAccounts<'_, 'info>, +) -> Result<(), ProgramError> { + // Extract tree accounts from CpiAccounts + let output_queue = cpi_accounts + .get_tree_account_info(params.output_queue_index as usize) + .map_err(|_| LightTokenError::MissingOutputQueue)? + .clone(); + let state_merkle_tree = cpi_accounts + .get_tree_account_info(params.state_tree_index as usize) + .map_err(|_| LightTokenError::MissingStateMerkleTree)? + .clone(); + let address_tree = cpi_accounts + .get_tree_account_info(params.address_tree_index as usize) + .map_err(|_| LightTokenError::MissingAddressMerkleTree)? + .clone(); + + // Build system accounts from CpiAccounts + let system_accounts = SystemAccountInfos { + light_system_program: cpi_accounts + .light_system_program() + .map_err(|_| LightTokenError::MissingLightSystemProgram)? + .clone(), + cpi_authority_pda: infra.cpi_authority, + registered_program_pda: cpi_accounts + .registered_program_pda() + .map_err(|_| LightTokenError::MissingRegisteredProgramPda)? + .clone(), + account_compression_authority: cpi_accounts + .account_compression_authority() + .map_err(|_| LightTokenError::MissingAccountCompressionAuthority)? + .clone(), + account_compression_program: cpi_accounts + .account_compression_program() + .map_err(|_| LightTokenError::MissingAccountCompressionProgram)? + .clone(), + system_program: cpi_accounts + .system_program() + .map_err(|_| LightTokenError::MissingSystemProgram)? + .clone(), + }; + + // Build and invoke CreateMintsCpi + CreateMintsCpi { + mint_seed_accounts, + payer: infra.fee_payer, + address_tree, + output_queue, + state_merkle_tree, + compressible_config: infra.compressible_config, + mints: mint_accounts, + rent_sponsor: infra.rent_sponsor, + system_accounts, + cpi_context_account: cpi_accounts + .cpi_context() + .map_err(|_| LightTokenError::MissingCpiContext)? + .clone(), + params, + } + .invoke() +} diff --git a/sdk-libs/token-sdk/src/compressible/mod.rs b/sdk-libs/token-sdk/src/compressible/mod.rs index 91c97c24ca..3d742ca29f 100644 --- a/sdk-libs/token-sdk/src/compressible/mod.rs +++ b/sdk-libs/token-sdk/src/compressible/mod.rs @@ -1,8 +1,12 @@ -//! Compressible token utilities for runtime decompression. +//! Compressible token utilities for runtime compression and decompression. +mod compress_runtime; mod decompress_runtime; +mod mint_runtime; +pub use compress_runtime::*; pub use decompress_runtime::*; +pub use mint_runtime::*; use solana_account_info::AccountInfo; #[derive(Debug, Clone)] diff --git a/sdk-libs/token-sdk/src/error.rs b/sdk-libs/token-sdk/src/error.rs index 8c80160011..25fe01cf5b 100644 --- a/sdk-libs/token-sdk/src/error.rs +++ b/sdk-libs/token-sdk/src/error.rs @@ -37,6 +37,26 @@ pub enum LightTokenError { InvalidAccountData, #[error("Serialization error")] SerializationError, + #[error("Missing CPI context account")] + MissingCpiContext, + #[error("Missing CPI authority account")] + MissingCpiAuthority, + #[error("Missing output queue account")] + MissingOutputQueue, + #[error("Missing state merkle tree account")] + MissingStateMerkleTree, + #[error("Missing address merkle tree account")] + MissingAddressMerkleTree, + #[error("Missing light system program")] + MissingLightSystemProgram, + #[error("Missing registered program PDA")] + MissingRegisteredProgramPda, + #[error("Missing account compression authority")] + MissingAccountCompressionAuthority, + #[error("Missing account compression program")] + MissingAccountCompressionProgram, + #[error("Missing system program")] + MissingSystemProgram, } impl From for ProgramError { @@ -59,6 +79,16 @@ impl From for u32 { LightTokenError::SplTokenProgramMismatch => 17508, LightTokenError::InvalidAccountData => 17509, LightTokenError::SerializationError => 17510, + LightTokenError::MissingCpiContext => 17511, + LightTokenError::MissingCpiAuthority => 17512, + LightTokenError::MissingOutputQueue => 17513, + LightTokenError::MissingStateMerkleTree => 17514, + LightTokenError::MissingAddressMerkleTree => 17515, + LightTokenError::MissingLightSystemProgram => 17516, + LightTokenError::MissingRegisteredProgramPda => 17517, + LightTokenError::MissingAccountCompressionAuthority => 17518, + LightTokenError::MissingAccountCompressionProgram => 17519, + LightTokenError::MissingSystemProgram => 17520, } } } 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 index f233224c4b..67c6012a26 100644 --- 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 @@ -12,10 +12,9 @@ use light_sdk_macros::LightAccount; #[compress_as(cached_time = 0, end_time = None)] #[account] pub struct AllCompositionRecord { - // compression_info in middle position + pub compression_info: Option, pub owner: Pubkey, pub delegate: Pubkey, - pub compression_info: Option, pub authority: Pubkey, pub close_authority: Option, #[max_len(64)] diff --git a/sdk-tests/single-account-loader-test/Cargo.toml b/sdk-tests/single-account-loader-test/Cargo.toml new file mode 100644 index 0000000000..5ec67b452b --- /dev/null +++ b/sdk-tests/single-account-loader-test/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "single-account-loader-test" +version = "0.1.0" +description = "Minimal Anchor program test for single AccountLoader (zero-copy) macro validation" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "single_account_loader_test" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +custom-heap = ["light-heap", "light-sdk/custom-heap"] +default = [] +idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build"] +test-sbf = [] + +[dependencies] +light-heap = { workspace = true, optional = true } +light-sdk = { workspace = true, features = ["anchor", "idl-build", "v2", "anchor-discriminator", "cpi-context"] } +light-sdk-types = { workspace = true, features = ["v2", "cpi-context"] } +light-sdk-macros = { workspace = true } +borsh = { workspace = true } +bytemuck = { workspace = true, features = ["derive"] } +light-compressible = { workspace = true, features = ["anchor"] } +light-token = { workspace = true, features = ["anchor"] } +anchor-lang = { workspace = true, features = ["idl-build"] } +solana-program = { workspace = true } +solana-pubkey = { workspace = true } +solana-msg = { workspace = true } +solana-program-error = { workspace = true } +solana-account-info = { workspace = true } + +[dev-dependencies] +light-program-test = { workspace = true, features = ["devenv"] } +light-client = { workspace = true, features = ["v2", "anchor"] } +light-test-utils = { workspace = true } +light-token = { workspace = true } +light-compressed-account = { workspace = true, features = ["solana"] } +light-compressible = { workspace = true, features = ["anchor"] } +tokio = { workspace = true } +solana-sdk = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +solana-keypair = { workspace = true } +solana-signer = { workspace = true } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/sdk-tests/single-account-loader-test/src/lib.rs b/sdk-tests/single-account-loader-test/src/lib.rs new file mode 100644 index 0000000000..eb2febe9c3 --- /dev/null +++ b/sdk-tests/single-account-loader-test/src/lib.rs @@ -0,0 +1,76 @@ +//! Minimal test program for `#[light_account(init, zero_copy)]` validation. +//! +//! This program tests ONLY the compressible PDA creation macro with AccountLoader +//! in isolation, ensuring zero-copy (Pod) accounts compile and work correctly. + +#![allow(deprecated)] + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk::derive_light_cpi_signer; +use light_sdk_macros::{light_program, LightAccounts}; +use light_sdk_types::CpiSigner; + +pub mod state; + +pub use state::*; + +declare_id!("ZCpy111111111111111111111111111111111111111"); + +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("ZCpy111111111111111111111111111111111111111"); + +pub const RECORD_SEED: &[u8] = b"zero_copy_record"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreateRecordParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Accounts struct for creating a zero-copy record. +/// Uses AccountLoader for Pod (zero-copy) account access. +#[derive(Accounts, LightAccounts)] +#[instruction(params: CreateRecordParams)] +pub struct CreateRecord<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config PDA + pub compression_config: AccountInfo<'info>, + + /// The zero-copy record account. + /// Uses AccountLoader which requires `#[light_account(init, zero_copy)]`. + #[account( + init, + payer = fee_payer, + space = 8 + core::mem::size_of::(), + seeds = [RECORD_SEED, params.owner.as_ref()], + bump, + )] + #[light_account(init, zero_copy)] + pub record: AccountLoader<'info, ZeroCopyRecord>, + + pub system_program: Program<'info, System>, +} + +#[light_program] +#[program] +pub mod single_account_loader_test { + use super::*; + + /// Create a single compressible zero-copy PDA. + /// The account is created by Anchor and made compressible by the + /// LightFinalize trait implementation generated by `#[light_account(init, zero_copy)]`. + pub fn create_record<'info>( + ctx: Context<'_, '_, '_, 'info, CreateRecord<'info>>, + params: CreateRecordParams, + ) -> Result<()> { + // Initialize the record data using load_init for zero-copy access + let mut record = ctx.accounts.record.load_init()?; + record.owner = params.owner.to_bytes(); + record.counter = 0; + // compression_info is handled by the macro-generated LightFinalize + Ok(()) + } +} diff --git a/sdk-tests/single-account-loader-test/src/state.rs b/sdk-tests/single-account-loader-test/src/state.rs new file mode 100644 index 0000000000..d24a7f4d3c --- /dev/null +++ b/sdk-tests/single-account-loader-test/src/state.rs @@ -0,0 +1,49 @@ +//! State module for single-account-loader-test. +//! +//! Defines a Pod (zero-copy) account struct for testing AccountLoader with Light Protocol. + +use anchor_lang::prelude::*; +use light_sdk::interface::CompressionInfo; // SDK version (24 bytes, Pod-compatible) +use light_sdk::LightDiscriminator; +use light_sdk_macros::PodCompressionInfoField; + +/// A zero-copy account using Pod serialization. +/// This account is used with AccountLoader and requires `#[light_account(init, zero_copy)]`. +/// +/// Key differences from Borsh-serialized accounts: +/// - Uses `#[repr(C)]` for predictable memory layout +/// - Implements `Pod` + `Zeroable` from bytemuck +/// - Uses non-optional SDK `CompressionInfo` (24 bytes, state indicated by `state` field) +/// - Fixed size at compile time via `core::mem::size_of::()` +#[derive(PodCompressionInfoField)] +#[account(zero_copy)] +#[repr(C)] +pub struct ZeroCopyRecord { + /// Owner of this record (stored as bytes for Pod compatibility). + pub owner: [u8; 32], + /// A simple counter value. + pub counter: u64, + /// Compression state - required for all rent-free accounts. + /// Uses SDK CompressionInfo (24 bytes): + /// - `state == Uninitialized` means not yet set up + /// - `state == Decompressed` means initialized/decompressed + /// - `state == Compressed` means compressed + pub compression_info: CompressionInfo, +} + +impl LightDiscriminator for ZeroCopyRecord { + // Must match Anchor's discriminator: sha256("account:ZeroCopyRecord")[0..8] + // This is computed by Anchor's #[account(zero_copy)] attribute + const LIGHT_DISCRIMINATOR: [u8; 8] = [55, 26, 139, 203, 102, 125, 85, 82]; + const LIGHT_DISCRIMINATOR_SLICE: &'static [u8] = &Self::LIGHT_DISCRIMINATOR; +} + +impl Default for ZeroCopyRecord { + fn default() -> Self { + Self { + owner: [0u8; 32], + counter: 0, + compression_info: CompressionInfo::default(), + } + } +} diff --git a/sdk-tests/single-account-loader-test/tests/test.rs b/sdk-tests/single-account-loader-test/tests/test.rs new file mode 100644 index 0000000000..ac17781228 --- /dev/null +++ b/sdk-tests/single-account-loader-test/tests/test.rs @@ -0,0 +1,289 @@ +//! Integration test for single AccountLoader (zero-copy) macro validation. + +use anchor_lang::{InstructionData, ToAccountMetas}; +use light_client::interface::{ + create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, AccountSpec, + CreateAccountsProofInput, InitializeRentFreeConfig, PdaSpec, +}; +use light_compressible::rent::SLOTS_PER_EPOCH; +use light_program_test::{ + program_test::{setup_mock_program_data, LightProgramTest, TestRpc}, + Indexer, ProgramTestConfig, Rpc, +}; +use light_sdk::interface::IntoVariant; +use light_token::instruction::RENT_SPONSOR; +use single_account_loader_test::{ + single_account_loader_test::{LightAccountVariant, RecordSeeds}, + CreateRecordParams, ZeroCopyRecord, RECORD_SEED, +}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +/// Test creating a single compressible zero-copy PDA using the macro. +/// Validates that `#[light_account(init, zero_copy)]` works with AccountLoader. +#[tokio::test] +async fn test_create_zero_copy_record() { + let program_id = single_account_loader_test::ID; + let mut config = + ProgramTestConfig::new_v2(true, Some(vec![("single_account_loader_test", program_id)])); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + let owner = Keypair::new().pubkey(); + + // Derive PDA for record using the same seeds as the program + let (record_pda, _) = + Pubkey::find_program_address(&[RECORD_SEED, owner.as_ref()], &program_id); + + // Get proof for the PDA + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = single_account_loader_test::accounts::CreateRecord { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = single_account_loader_test::instruction::CreateRecord { + params: CreateRecordParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("CreateRecord should succeed"); + + // Verify PDA exists on-chain + let record_account = rpc + .get_account(record_pda) + .await + .unwrap() + .expect("Record PDA should exist on-chain"); + + // Parse and verify record data using bytemuck (zero-copy deserialization) + // Skip the 8-byte discriminator + let discriminator_len = 8; + let data = &record_account.data[discriminator_len..]; + let record: &ZeroCopyRecord = bytemuck::from_bytes(data); + + // Verify owner field + assert_eq!(record.owner, owner.to_bytes(), "Record owner should match"); + + // Verify counter field + assert_eq!(record.counter, 0, "Record counter should be 0"); + + // Verify compression_info is set (state == Decompressed indicates initialized) + use light_sdk::interface::CompressionState; + assert_eq!( + record.compression_info.state, CompressionState::Decompressed, + "state should be Decompressed (initialized)" + ); + assert_eq!( + record.compression_info.config_version, 1, + "config_version should be 1" + ); +} + +/// Test the full lifecycle of a zero-copy PDA: create -> compress -> decompress. +/// Validates that the macro correctly handles Pod accounts through all phases. +#[tokio::test] +async fn test_zero_copy_record_full_lifecycle() { + let program_id = single_account_loader_test::ID; + let mut config = + ProgramTestConfig::new_v2(true, Some(vec![("single_account_loader_test", program_id)])); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + let owner = Keypair::new().pubkey(); + + // Derive PDA for record using the same seeds as the program + let (record_pda, _) = + Pubkey::find_program_address(&[RECORD_SEED, owner.as_ref()], &program_id); + + // Get proof for the PDA + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = single_account_loader_test::accounts::CreateRecord { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = single_account_loader_test::instruction::CreateRecord { + params: CreateRecordParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("CreateRecord should succeed"); + + // PHASE 1: Verify account exists on-chain + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Account should exist on-chain after creation" + ); + + // PHASE 2: Warp time to trigger forester auto-compression + rpc.warp_slot_forward(SLOTS_PER_EPOCH * 30).await.unwrap(); + + // Verify account is closed on-chain (compressed by forester) + let acc = rpc.get_account(record_pda).await.unwrap(); + assert!( + acc.is_none() || acc.unwrap().lamports == 0, + "Account should be closed after compression" + ); + + // PHASE 3: Verify compressed account exists + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_address = light_compressed_account::address::derive_address( + &record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let compressed_acc = rpc + .get_compressed_account(compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + assert_eq!( + compressed_acc.address.unwrap(), + compressed_address, + "Compressed account address should match" + ); + assert!( + !compressed_acc.data.as_ref().unwrap().data.is_empty(), + "Compressed account should have data" + ); + + // PHASE 4: Decompress account + let account_interface = rpc + .get_account_interface(&record_pda, &program_id) + .await + .expect("failed to get account interface"); + assert!(account_interface.is_cold(), "Account should be cold (compressed)"); + + // Build variant using IntoVariant - verify seeds match the compressed data + let variant = RecordSeeds { owner } + .into_variant(&account_interface.account.data[8..]) + .expect("Seed verification failed"); + + // Build PdaSpec and create decompress instructions + let spec = PdaSpec::new(account_interface.clone(), variant, program_id); + let specs: Vec> = vec![AccountSpec::Pda(spec)]; + + let decompress_instructions = create_load_instructions( + &specs, + payer.pubkey(), + config_pda, + payer.pubkey(), + &rpc, + ) + .await + .expect("create_load_instructions should succeed"); + + rpc.create_and_send_transaction(&decompress_instructions, &payer.pubkey(), &[&payer]) + .await + .expect("Decompression should succeed"); + + // PHASE 5: Verify account is back on-chain with correct data + let record_account = rpc + .get_account(record_pda) + .await + .unwrap() + .expect("Account should exist after decompression"); + + // Verify data is correct using bytemuck (zero-copy deserialization) + let discriminator_len = 8; + let data = &record_account.data[discriminator_len..]; + let record: &ZeroCopyRecord = bytemuck::from_bytes(data); + + assert_eq!(record.owner, owner.to_bytes(), "Record owner should match"); + assert_eq!(record.counter, 0, "Record counter should still be 0"); + // state should be Decompressed after decompression + use light_sdk::interface::CompressionState; + assert_eq!( + record.compression_info.state, CompressionState::Decompressed, + "state should be Decompressed after decompression" + ); + assert!( + record.compression_info.config_version >= 1, + "config_version should be >= 1 after decompression" + ); +}