diff --git a/Cargo.lock b/Cargo.lock index ddcc2a7f08..e356f741f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4027,6 +4027,7 @@ name = "light-sdk-macros" version = "0.17.1" dependencies = [ "borsh 0.10.4", + "darling", "light-account-checks", "light-compressed-account", "light-hasher", diff --git a/Cargo.toml b/Cargo.toml index a498d7a733..f1c4aae7f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,7 +142,8 @@ serde_json = "1.0" # Macro helpers proc-macro2 = "1.0" quote = "1.0" -syn = { version = "2.0", features = ["visit-mut", "full"] } +syn = { version = "2.0", features = ["visit", "visit-mut", "full"] } +darling = "0.21" # Async ecosystem futures = "0.3.31" diff --git a/sdk-libs/macros/Cargo.toml b/sdk-libs/macros/Cargo.toml index 57e64941c4..dba703dfe0 100644 --- a/sdk-libs/macros/Cargo.toml +++ b/sdk-libs/macros/Cargo.toml @@ -14,6 +14,7 @@ anchor-discriminator = [] proc-macro2 = { workspace = true } quote = { workspace = true } syn = { workspace = true } +darling = { workspace = true } solana-pubkey = { workspace = true, features = ["curve25519", "sha2"] } light-hasher = { workspace = true, features = ["sha256"] } diff --git a/sdk-libs/macros/src/compressible/README.md b/sdk-libs/macros/src/compressible/README.md deleted file mode 100644 index aac0ceb9e3..0000000000 --- a/sdk-libs/macros/src/compressible/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Compressible Macros - -Procedural macros for generating rent-free account types and their hooks for Solana programs. - -## Files - -**`mod.rs`** - Module declaration - -**`traits.rs`** - Core trait implementations - -- `HasCompressionInfo` - CompressionInfo field access -- `CompressAs` - Field-level compression control -- `Compressible` - Full trait bundle (Size + HasCompressionInfo + CompressAs) - -**`pack_unpack.rs`** - Pubkey compression - -- Generates `PackedXxx` structs where Pubkey fields become u8 indices -- Implements Pack/Unpack traits for serialization efficiency - -**`variant_enum.rs`** - Account variant enum - -- Generates `RentFreeAccountVariant` enum from account types -- Implements all required traits (Default, DataHasher, Size, Pack, Unpack) -- Creates `RentFreeAccountData` wrapper struct - -**`instructions.rs`** - Instruction generation - -- Main macro: `#[rentfree]` -- Generates compress/decompress instruction handlers -- Creates context structs and account validation -- **Compress**: PDA-only (ctokens compressed via registry) -- **Decompress**: Full PDA + ctoken support - -**`seed_providers.rs`** - Seed derivation - -- PDA seed provider implementations -- CToken seed provider with account/authority derivation -- Client-side seed functions for off-chain use - -**`decompress_context.rs`** - Decompression trait - -- Generates `DecompressContext` implementation -- Account accessor methods -- PDA/token separation logic -- Token processing delegation diff --git a/sdk-libs/macros/src/compressible/file_scanner.rs b/sdk-libs/macros/src/compressible/file_scanner.rs deleted file mode 100644 index ea52500298..0000000000 --- a/sdk-libs/macros/src/compressible/file_scanner.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! File scanning for #[rentfree_program] macro. -//! -//! This module reads external Rust source files to extract seed information -//! from Accounts structs that contain #[rentfree] fields. - -use std::path::{Path, PathBuf}; - -use syn::{Item, ItemMod, ItemStruct}; - -use crate::compressible::anchor_seeds::{ - extract_from_accounts_struct, ExtractedAccountsInfo, ExtractedSeedSpec, ExtractedTokenSpec, -}; - -/// Result of scanning a module and its external files -#[derive(Debug, Default)] -pub struct ScannedModuleInfo { - pub pda_specs: Vec, - pub token_specs: Vec, - pub errors: Vec, - /// Names of Accounts structs that have rentfree fields (for auto-wrapping handlers) - pub rentfree_struct_names: std::collections::HashSet, -} - -/// Scan the entire src/ directory for Accounts structs with #[rentfree] fields. -/// -/// This function scans all .rs files in the crate's src/ directory -/// and extracts seed information from Accounts structs. -pub fn scan_module_for_compressible( - _module: &ItemMod, - base_path: &Path, -) -> syn::Result { - let mut result = ScannedModuleInfo::default(); - - // Scan all .rs files in the src directory - scan_directory_recursive(base_path, &mut result); - - Ok(result) -} - -/// Recursively scan a directory for .rs files -fn scan_directory_recursive(dir: &Path, result: &mut ScannedModuleInfo) { - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(e) => { - result - .errors - .push(format!("Failed to read directory {:?}: {}", dir, e)); - return; - } - }; - - for entry in entries.flatten() { - let path = entry.path(); - - if path.is_dir() { - scan_directory_recursive(&path, result); - } else if path.extension().map(|e| e == "rs").unwrap_or(false) { - scan_rust_file(&path, result); - } - } -} - -/// Scan a single Rust file for Accounts structs -fn scan_rust_file(path: &Path, result: &mut ScannedModuleInfo) { - let contents = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - result - .errors - .push(format!("Failed to read {:?}: {}", path, e)); - return; - } - }; - - let parsed: syn::File = match syn::parse_str(&contents) { - Ok(f) => f, - Err(e) => { - // Not all files may be valid on their own (e.g., test files with main) - // Just skip them silently - let _ = e; - return; - } - }; - - for item in parsed.items { - match item { - Item::Struct(item_struct) => { - if let Ok(Some((info, struct_name))) = try_extract_from_struct(&item_struct) { - result.pda_specs.extend(info.pda_fields); - result.token_specs.extend(info.token_fields); - result.rentfree_struct_names.insert(struct_name); - } - } - Item::Mod(inner_mod) if inner_mod.content.is_some() => { - // Inline module - recursively scan - scan_inline_module(&inner_mod, result); - } - _ => {} - } - } -} - -/// Scan an inline module for Accounts structs -fn scan_inline_module(module: &ItemMod, result: &mut ScannedModuleInfo) { - let content = match &module.content { - Some((_, items)) => items, - None => return, - }; - - for item in content { - match item { - Item::Struct(item_struct) => { - if let Ok(Some((info, struct_name))) = try_extract_from_struct(item_struct) { - result.pda_specs.extend(info.pda_fields); - result.token_specs.extend(info.token_fields); - result.rentfree_struct_names.insert(struct_name); - } - } - Item::Mod(inner_mod) if inner_mod.content.is_some() => { - scan_inline_module(inner_mod, result); - } - _ => {} - } - } -} - -/// Try to extract rentfree info from a struct. -/// Returns (ExtractedAccountsInfo, struct_name) if the struct has rentfree fields. -fn try_extract_from_struct( - item_struct: &ItemStruct, -) -> syn::Result> { - // Check if it has #[derive(Accounts)] - let has_accounts_derive = item_struct.attrs.iter().any(|attr| { - if attr.path().is_ident("derive") { - if let Ok(meta) = attr.parse_args_with( - syn::punctuated::Punctuated::::parse_terminated, - ) { - return meta.iter().any(|p| p.is_ident("Accounts")); - } - } - false - }); - - if !has_accounts_derive { - return Ok(None); - } - - let info = extract_from_accounts_struct(item_struct)?; - match info { - Some(extracted) => { - let struct_name = extracted.struct_name.to_string(); - Ok(Some((extracted, struct_name))) - } - None => Ok(None), - } -} - -/// Resolve the base path for the crate source -/// -/// This attempts to find the src/ directory by looking at CARGO_MANIFEST_DIR -/// or falling back to current directory. -pub fn resolve_crate_src_path() -> PathBuf { - // Try CARGO_MANIFEST_DIR first (set during cargo build) - if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { - let src_path = PathBuf::from(&manifest_dir).join("src"); - if src_path.exists() { - return src_path; - } - // Fallback to manifest dir itself - return PathBuf::from(manifest_dir); - } - - // Fallback to current directory - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join("src") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_resolve_path() { - let path = resolve_crate_src_path(); - println!("Resolved path: {:?}", path); - } -} diff --git a/sdk-libs/macros/src/compressible/instructions.rs b/sdk-libs/macros/src/compressible/instructions.rs deleted file mode 100644 index 50265641ae..0000000000 --- a/sdk-libs/macros/src/compressible/instructions.rs +++ /dev/null @@ -1,1606 +0,0 @@ -//! Compressible instructions generation. - -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{ - parse::{Parse, ParseStream}, - punctuated::Punctuated, - Expr, Ident, Item, ItemMod, LitStr, Result, Token, -}; - -/// Convert PascalCase to snake_case (e.g., UserRecord -> user_record) -fn to_snake_case(s: &str) -> String { - let mut result = String::new(); - for (i, c) in s.chars().enumerate() { - if c.is_uppercase() { - if i > 0 { - result.push('_'); - } - result.push(c.to_ascii_lowercase()); - } else { - result.push(c); - } - } - result -} - -macro_rules! macro_error { - ($span:expr, $msg:expr) => { - syn::Error::new_spanned( - $span, - format!( - "{}\n --> macro location: {}:{}", - $msg, - file!(), - line!() - ) - ) - }; - ($span:expr, $fmt:expr, $($arg:tt)*) => { - syn::Error::new_spanned( - $span, - format!( - concat!($fmt, "\n --> macro location: {}:{}"), - $($arg)*, - file!(), - line!() - ) - ) - }; -} - -#[derive(Debug, Clone, Copy)] -pub enum InstructionVariant { - PdaOnly, - TokenOnly, - Mixed, -} - -#[derive(Clone)] -pub struct TokenSeedSpec { - pub variant: Ident, - pub _eq: Token![=], - pub is_token: Option, - pub seeds: Punctuated, - pub authority: Option>, -} - -impl Parse for TokenSeedSpec { - fn parse(input: ParseStream) -> Result { - let variant: Ident = input.parse()?; - let _eq: Token![=] = input.parse()?; - - let content; - syn::parenthesized!(content in input); - - // New explicit syntax: - // PDA: TypeName = (seeds = (...)) - // Token: TypeName = (is_token, seeds = (...), authority = (...)) - let mut is_token = None; - let mut seeds = Punctuated::new(); - let mut authority = None; - - while !content.is_empty() { - if content.peek(Ident) { - let ident: Ident = content.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "is_token" | "true" => { - is_token = Some(true); - } - "is_pda" | "false" => { - is_token = Some(false); - } - "seeds" => { - let _eq: Token![=] = content.parse()?; - let seeds_content; - syn::parenthesized!(seeds_content in content); - seeds = parse_seed_elements(&seeds_content)?; - } - "authority" => { - let _eq: Token![=] = content.parse()?; - authority = Some(parse_authority_seeds(&content)?); - } - _ => { - return Err(syn::Error::new_spanned( - &ident, - format!( - "Unknown keyword '{}'. Expected: is_token, seeds, or authority.\n\ - Use explicit syntax: TypeName = (seeds = (\"seed\", ctx.account, ...))\n\ - For tokens: TypeName = (is_token, seeds = (...), authority = (...))", - ident_str - ), - )); - } - } - } else { - return Err(syn::Error::new( - content.span(), - "Expected keyword (is_token, seeds, or authority). Use explicit syntax:\n\ - - PDA: TypeName = (seeds = (\"seed\", ctx.account, ...))\n\ - - Token: TypeName = (is_token, seeds = (...), authority = (...))", - )); - } - - if content.peek(Token![,]) { - let _comma: Token![,] = content.parse()?; - } else { - break; - } - } - - if seeds.is_empty() { - return Err(syn::Error::new_spanned( - &variant, - format!( - "Missing seeds for '{}'. Use: {} = (seeds = (\"seed\", ctx.account, ...))", - variant, variant - ), - )); - } - - Ok(TokenSeedSpec { - variant, - _eq, - is_token, - seeds, - authority, - }) - } -} - -/// Parse seed elements from within seeds = (...) -fn parse_seed_elements(content: ParseStream) -> Result> { - let mut seeds = Punctuated::new(); - - while !content.is_empty() { - seeds.push(content.parse::()?); - - if content.peek(Token![,]) { - let _: Token![,] = content.parse()?; - if content.is_empty() { - break; - } - } else { - break; - } - } - - Ok(seeds) -} - -/// Parse authority seeds - either parenthesized tuple or single expression -fn parse_authority_seeds(content: ParseStream) -> Result> { - if content.peek(syn::token::Paren) { - let auth_content; - syn::parenthesized!(auth_content in content); - let mut auth_seeds = Vec::new(); - - while !auth_content.is_empty() { - auth_seeds.push(auth_content.parse::()?); - if auth_content.peek(Token![,]) { - let _: Token![,] = auth_content.parse()?; - } else { - break; - } - } - Ok(auth_seeds) - } else { - // Single expression (e.g., LIGHT_CPI_SIGNER) - Ok(vec![content.parse::()?]) - } -} - -#[derive(Clone)] -pub enum SeedElement { - Literal(LitStr), - Expression(Box), -} - -impl Parse for SeedElement { - fn parse(input: ParseStream) -> Result { - if input.peek(LitStr) { - Ok(SeedElement::Literal(input.parse()?)) - } else { - Ok(SeedElement::Expression(input.parse()?)) - } - } -} - -/// Recursively extract all ctx.XXX or ctx.accounts.XXX field names from an expression. -/// Handles nested expressions like function calls: max_key(&ctx.user.key(), &ctx.authority.key()) -fn extract_ctx_fields_from_expr(expr: &syn::Expr, fields: &mut Vec) { - match expr { - syn::Expr::Field(field_expr) => { - if let syn::Member::Named(field_name) = &field_expr.member { - // Check for ctx.XXX pattern (direct field access) - if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - fields.push(field_name.clone()); - return; - } - } - } - // Check for ctx.accounts.XXX pattern (nested field access) - if let syn::Expr::Field(nested_field) = &*field_expr.base { - if let syn::Member::Named(base_name) = &nested_field.member { - if base_name == "accounts" { - if let syn::Expr::Path(path) = &*nested_field.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - fields.push(field_name.clone()); - return; - } - } - } - } - } - } - } - // Recurse into base expression - extract_ctx_fields_from_expr(&field_expr.base, fields); - } - syn::Expr::MethodCall(method) => { - // Recurse into receiver and args - extract_ctx_fields_from_expr(&method.receiver, fields); - for arg in &method.args { - extract_ctx_fields_from_expr(arg, fields); - } - } - syn::Expr::Call(call) => { - // Recurse into function args - for arg in &call.args { - extract_ctx_fields_from_expr(arg, fields); - } - } - syn::Expr::Reference(ref_expr) => { - extract_ctx_fields_from_expr(&ref_expr.expr, fields); - } - syn::Expr::Paren(paren) => { - extract_ctx_fields_from_expr(&paren.expr, fields); - } - _ => {} - } -} - -/// Extract ctx.XXX or ctx.accounts.XXX field names from a seed element. -fn extract_ctx_account_fields(seed: &SeedElement) -> Vec { - let mut fields = Vec::new(); - if let SeedElement::Expression(expr) = seed { - extract_ctx_fields_from_expr(expr, &mut fields); - } - fields -} - -/// Extract all ctx.accounts.XXX field names from a list of seed elements. -/// Deduplicates the fields. -pub fn extract_ctx_seed_fields( - seeds: &syn::punctuated::Punctuated, -) -> Vec { - let mut all_fields = Vec::new(); - for seed in seeds { - all_fields.extend(extract_ctx_account_fields(seed)); - } - // Deduplicate while preserving order - let mut seen = std::collections::HashSet::new(); - all_fields - .into_iter() - .filter(|f| seen.insert(f.to_string())) - .collect() -} - -/// Phase 5: Extract data.XXX field names from an expression recursively. -fn extract_data_fields_from_expr(expr: &syn::Expr, fields: &mut Vec) { - match expr { - syn::Expr::Field(field_expr) => { - if let syn::Member::Named(field_name) = &field_expr.member { - // Check for data.XXX pattern - if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "data" { - fields.push(field_name.clone()); - return; - } - } - } - } - // Recurse into base expression - extract_data_fields_from_expr(&field_expr.base, fields); - } - syn::Expr::MethodCall(method) => { - extract_data_fields_from_expr(&method.receiver, fields); - for arg in &method.args { - extract_data_fields_from_expr(arg, fields); - } - } - syn::Expr::Call(call) => { - for arg in &call.args { - extract_data_fields_from_expr(arg, fields); - } - } - syn::Expr::Reference(ref_expr) => { - extract_data_fields_from_expr(&ref_expr.expr, fields); - } - syn::Expr::Paren(paren) => { - extract_data_fields_from_expr(&paren.expr, fields); - } - _ => {} - } -} - -/// Phase 5: Extract all data.XXX field names from a list of seed elements. -pub fn extract_data_seed_fields( - seeds: &syn::punctuated::Punctuated, -) -> Vec { - let mut all_fields = Vec::new(); - for seed in seeds { - if let SeedElement::Expression(expr) = seed { - extract_data_fields_from_expr(expr, &mut all_fields); - } - } - // Deduplicate while preserving order - let mut seen = std::collections::HashSet::new(); - all_fields - .into_iter() - .filter(|f| seen.insert(f.to_string())) - .collect() -} - -pub struct InstructionDataSpec { - pub field_name: Ident, - pub field_type: syn::Type, -} - -impl Parse for InstructionDataSpec { - fn parse(input: ParseStream) -> Result { - let field_name: Ident = input.parse()?; - let _eq: Token![=] = input.parse()?; - let field_type: syn::Type = input.parse()?; - - Ok(InstructionDataSpec { - field_name, - field_type, - }) - } -} - -pub fn generate_decompress_context_impl( - _variant: InstructionVariant, - pda_ctx_seeds: Vec, - token_variant_ident: Ident, -) -> Result { - let lifetime: syn::Lifetime = syn::parse_quote!('info); - - let trait_impl = - crate::compressible::decompress_context::generate_decompress_context_trait_impl( - pda_ctx_seeds, - token_variant_ident, - lifetime, - )?; - - Ok(syn::parse_quote! { - mod __decompress_context_impl { - use super::*; - - #trait_impl - } - }) -} - -pub fn generate_process_decompress_accounts_idempotent( - _variant: InstructionVariant, - _instruction_data: &[InstructionDataSpec], -) -> Result { - // Phase 4: seed_data removed - data.* seeds come from unpacked account data, ctx.* from variant idx - Ok(syn::parse_quote! { - #[inline(never)] - pub fn process_decompress_accounts_idempotent<'info>( - accounts: &DecompressAccountsIdempotent<'info>, - remaining_accounts: &[solana_account_info::AccountInfo<'info>], - proof: light_sdk::instruction::ValidityProof, - compressed_accounts: Vec, - system_accounts_offset: u8, - ) -> Result<()> { - light_sdk::compressible::process_decompress_accounts_idempotent( - accounts, - remaining_accounts, - compressed_accounts, - proof, - system_accounts_offset, - LIGHT_CPI_SIGNER, - &crate::ID, - std::option::Option::None::<&()>, - ) - .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) - } - }) -} - -pub fn generate_decompress_instruction_entrypoint( - _variant: InstructionVariant, - _instruction_data: &[InstructionDataSpec], -) -> Result { - // Phase 4: seed_data removed - data.* seeds come from unpacked account data, ctx.* from variant idx - - Ok(syn::parse_quote! { - #[inline(never)] - pub fn decompress_accounts_idempotent<'info>( - ctx: Context<'_, '_, '_, 'info, DecompressAccountsIdempotent<'info>>, - proof: light_sdk::instruction::ValidityProof, - compressed_accounts: Vec, - system_accounts_offset: u8, - ) -> Result<()> { - __processor_functions::process_decompress_accounts_idempotent( - &ctx.accounts, - &ctx.remaining_accounts, - proof, - compressed_accounts, - system_accounts_offset, - ) - } - }) -} - -pub fn generate_compress_context_impl( - _variant: InstructionVariant, - account_types: Vec, -) -> Result { - let lifetime: syn::Lifetime = syn::parse_quote!('info); - - let compress_arms: Vec<_> = account_types.iter().map(|name| { - quote! { - d if d == #name::LIGHT_DISCRIMINATOR => { - drop(data); - let data_borrow = account_info.try_borrow_data().map_err(|e| { - let err: anchor_lang::error::Error = e.into(); - let program_error: anchor_lang::prelude::ProgramError = err.into(); - let code = match program_error { - anchor_lang::prelude::ProgramError::Custom(code) => code, - _ => 0, - }; - solana_program_error::ProgramError::Custom(code) - })?; - let mut account_data = #name::try_deserialize(&mut &data_borrow[..]).map_err(|e| { - let err: anchor_lang::error::Error = e.into(); - let program_error: anchor_lang::prelude::ProgramError = err.into(); - let code = match program_error { - anchor_lang::prelude::ProgramError::Custom(code) => code, - _ => 0, - }; - solana_program_error::ProgramError::Custom(code) - })?; - drop(data_borrow); - - let compressed_info = light_sdk::compressible::compress_account::prepare_account_for_compression::<#name>( - program_id, - account_info, - &mut account_data, - meta, - cpi_accounts, - &compression_config.address_space, - )?; - // Lamport transfers are handled by close() in process_compress_pda_accounts_idempotent - // All lamports go to rent_sponsor for simplicity - Ok(Some(compressed_info)) - } - } - }).collect(); - - Ok(syn::parse_quote! { - mod __compress_context_impl { - use super::*; - use light_sdk::LightDiscriminator; - use light_sdk::compressible::HasCompressionInfo; - - impl<#lifetime> light_sdk::compressible::CompressContext<#lifetime> for CompressAccountsIdempotent<#lifetime> { - fn fee_payer(&self) -> &solana_account_info::AccountInfo<#lifetime> { - &*self.fee_payer - } - - fn config(&self) -> &solana_account_info::AccountInfo<#lifetime> { - &self.config - } - - fn rent_sponsor(&self) -> &solana_account_info::AccountInfo<#lifetime> { - &self.rent_sponsor - } - - fn compression_authority(&self) -> &solana_account_info::AccountInfo<#lifetime> { - &self.compression_authority - } - - fn compress_pda_account( - &self, - account_info: &solana_account_info::AccountInfo<#lifetime>, - meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, - cpi_accounts: &light_sdk::cpi::v2::CpiAccounts<'_, #lifetime>, - compression_config: &light_sdk::compressible::CompressibleConfig, - program_id: &solana_pubkey::Pubkey, - ) -> std::result::Result, solana_program_error::ProgramError> { - let data = account_info.try_borrow_data().map_err(|e| { - let err: anchor_lang::error::Error = e.into(); - let program_error: anchor_lang::prelude::ProgramError = err.into(); - let code = match program_error { - anchor_lang::prelude::ProgramError::Custom(code) => code, - _ => 0, - }; - solana_program_error::ProgramError::Custom(code) - })?; - let discriminator = &data[0..8]; - - match discriminator { - #(#compress_arms)* - _ => { - let err: anchor_lang::error::Error = anchor_lang::error::ErrorCode::AccountDiscriminatorMismatch.into(); - let program_error: anchor_lang::prelude::ProgramError = err.into(); - let code = match program_error { - anchor_lang::prelude::ProgramError::Custom(code) => code, - _ => 0, - }; - Err(solana_program_error::ProgramError::Custom(code)) - } - } - } - } - } - }) -} - -pub fn generate_process_compress_accounts_idempotent( - _variant: InstructionVariant, -) -> Result { - Ok(syn::parse_quote! { - #[inline(never)] - pub fn process_compress_accounts_idempotent<'info>( - accounts: &CompressAccountsIdempotent<'info>, - remaining_accounts: &[solana_account_info::AccountInfo<'info>], - compressed_accounts: Vec, - system_accounts_offset: u8, - ) -> Result<()> { - light_sdk::compressible::compress_runtime::process_compress_pda_accounts_idempotent( - accounts, - remaining_accounts, - compressed_accounts, - system_accounts_offset, - LIGHT_CPI_SIGNER, - &crate::ID, - ) - .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) - } - }) -} - -pub fn generate_compress_instruction_entrypoint( - _variant: InstructionVariant, -) -> Result { - Ok(syn::parse_quote! { - #[inline(never)] - #[allow(clippy::too_many_arguments)] - pub fn compress_accounts_idempotent<'info>( - ctx: Context<'_, '_, '_, 'info, CompressAccountsIdempotent<'info>>, - proof: light_sdk::instruction::ValidityProof, - compressed_accounts: Vec, - system_accounts_offset: u8, - ) -> Result<()> { - __processor_functions::process_compress_accounts_idempotent( - &ctx.accounts, - &ctx.remaining_accounts, - compressed_accounts, - system_accounts_offset, - ) - } - }) -} - -/// Phase 3: Generate PDA seed derivation that uses CtxSeeds struct instead of DecompressAccountsIdempotent. -/// Maps ctx.field -> ctx_seeds.field (direct Pubkey access, no Option unwrapping needed) -#[inline(never)] -fn generate_pda_seed_derivation_for_trait_with_ctx_seeds( - spec: &TokenSeedSpec, - _instruction_data: &[InstructionDataSpec], - ctx_seed_fields: &[syn::Ident], -) -> Result { - let mut bindings: Vec = Vec::new(); - let mut seed_refs = Vec::new(); - - // Convert ctx_seed_fields to a set for quick lookup - let ctx_field_names: std::collections::HashSet = - ctx_seed_fields.iter().map(|f| f.to_string()).collect(); - - // Recursively rewrite expressions: - // - `data.` -> `self.` (from unpacked compressed account data - Phase 4) - // - `ctx.accounts.` -> `ctx_seeds.` (direct Pubkey on CtxSeeds struct) - // - `ctx.` -> `ctx_seeds.` (direct Pubkey on CtxSeeds struct) - fn map_pda_expr_to_ctx_seeds( - expr: &syn::Expr, - ctx_field_names: &std::collections::HashSet, - ) -> syn::Expr { - match expr { - syn::Expr::Field(field_expr) => { - if let syn::Member::Named(field_name) = &field_expr.member { - // Handle nested field access: ctx.accounts.field_name -> ctx_seeds.field_name - if let syn::Expr::Field(nested_field) = &*field_expr.base { - if let syn::Member::Named(base_name) = &nested_field.member { - if base_name == "accounts" { - if let syn::Expr::Path(path) = &*nested_field.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - // ctx.accounts.field -> ctx_seeds.field (direct Pubkey) - return syn::parse_quote! { ctx_seeds.#field_name }; - } - } - } - } - } - } - // Handle direct field access - if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "data" { - // Phase 4: data.field -> self.field (from unpacked compressed account data) - return syn::parse_quote! { self.#field_name }; - } else if segment.ident == "ctx" { - let field_str = field_name.to_string(); - if ctx_field_names.contains(&field_str) { - // ctx.field -> ctx_seeds.field (direct Pubkey) - return syn::parse_quote! { ctx_seeds.#field_name }; - } - } - } - } - } - expr.clone() - } - syn::Expr::MethodCall(method_call) => { - let mut new_method_call = method_call.clone(); - new_method_call.receiver = Box::new(map_pda_expr_to_ctx_seeds( - &method_call.receiver, - ctx_field_names, - )); - new_method_call.args = method_call - .args - .iter() - .map(|a| map_pda_expr_to_ctx_seeds(a, ctx_field_names)) - .collect(); - syn::Expr::MethodCall(new_method_call) - } - syn::Expr::Call(call_expr) => { - let mut new_call_expr = call_expr.clone(); - new_call_expr.args = call_expr - .args - .iter() - .map(|a| map_pda_expr_to_ctx_seeds(a, ctx_field_names)) - .collect(); - syn::Expr::Call(new_call_expr) - } - syn::Expr::Reference(ref_expr) => { - let mut new_ref_expr = ref_expr.clone(); - new_ref_expr.expr = - Box::new(map_pda_expr_to_ctx_seeds(&ref_expr.expr, ctx_field_names)); - syn::Expr::Reference(new_ref_expr) - } - _ => expr.clone(), - } - } - - for (i, seed) in spec.seeds.iter().enumerate() { - match seed { - SeedElement::Literal(lit) => { - let value = lit.value(); - seed_refs.push(quote! { #value.as_bytes() }); - } - SeedElement::Expression(expr) => { - // Handle byte string literals: b"seed" -> use directly (no .as_bytes()) - if let syn::Expr::Lit(lit_expr) = &**expr { - if let syn::Lit::ByteStr(byte_str) = &lit_expr.lit { - let bytes = byte_str.value(); - seed_refs.push(quote! { &[#(#bytes),*] }); - continue; - } - } - - // Handle uppercase constants - if let syn::Expr::Path(path_expr) = &**expr { - if let Some(ident) = path_expr.path.get_ident() { - let ident_str = ident.to_string(); - if ident_str.chars().all(|c| c.is_uppercase() || c == '_') { - seed_refs.push( - quote! { { let __seed: &[u8] = crate::#ident.as_ref(); __seed } }, - ); - continue; - } - } - } - - let binding_name = - syn::Ident::new(&format!("seed_{}", i), proc_macro2::Span::call_site()); - let mapped_expr = map_pda_expr_to_ctx_seeds(expr, &ctx_field_names); - bindings.push(quote! { - let #binding_name = #mapped_expr; - }); - seed_refs.push(quote! { (#binding_name).as_ref() }); - } - } - } - - let indices: Vec = (0..seed_refs.len()).collect(); - - Ok(quote! { - #(#bindings)* - let seeds: &[&[u8]] = &[#(#seed_refs,)*]; - let (pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, program_id); - let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); - #( - seeds_vec.push(seeds[#indices].to_vec()); - )* - seeds_vec.push(vec![bump]); - Ok((seeds_vec, pda)) - }) -} - -#[inline(never)] -fn generate_decompress_accounts_struct( - required_accounts: &[String], - variant: InstructionVariant, -) -> Result { - let mut account_fields = vec![ - quote! { - #[account(mut)] - pub fee_payer: Signer<'info> - }, - quote! { - /// CHECK: Checked by SDK - pub config: AccountInfo<'info> - }, - ]; - - match variant { - InstructionVariant::PdaOnly => { - unreachable!() - } - InstructionVariant::TokenOnly => { - unreachable!() - } - InstructionVariant::Mixed => { - account_fields.extend(vec![ - quote! { - /// CHECK: anyone can pay - #[account(mut)] - pub rent_sponsor: UncheckedAccount<'info> - }, - quote! { - /// CHECK: optional - only needed if decompressing tokens - #[account(mut)] - pub ctoken_rent_sponsor: Option> - }, - ]); - } - } - - match variant { - InstructionVariant::TokenOnly => { - unreachable!() - } - InstructionVariant::Mixed => { - account_fields.extend(vec![ - quote! { - /// CHECK: - #[account(address = solana_pubkey::pubkey!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"))] - pub light_token_program: Option> - }, - quote! { - /// CHECK: - #[account(address = solana_pubkey::pubkey!("GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy"))] - pub ctoken_cpi_authority: Option> - }, - quote! { - /// CHECK: Checked by SDK - pub ctoken_config: Option> - }, - ]); - } - InstructionVariant::PdaOnly => { - unreachable!() - } - } - - let standard_fields = [ - "fee_payer", - "rent_sponsor", - "ctoken_rent_sponsor", - "config", - "light_token_program", - "ctoken_cpi_authority", - "ctoken_config", - ]; - - for account_name in required_accounts { - if !standard_fields.contains(&account_name.as_str()) { - let account_ident = syn::Ident::new(account_name, proc_macro2::Span::call_site()); - // Mark seed accounts as writable to support CPI calls that may need them writable - account_fields.push(quote! { - /// CHECK: optional seed account - may be used in CPIs - #[account(mut)] - pub #account_ident: Option> - }); - } - } - - let struct_def = quote! { - #[derive(Accounts)] - pub struct DecompressAccountsIdempotent<'info> { - #(#account_fields,)* - } - }; - - syn::parse2(struct_def) -} - -#[inline(never)] -fn validate_compressed_account_sizes(account_types: &[Ident]) -> Result { - let size_checks: Vec<_> = account_types.iter().map(|account_type| { - quote! { - const _: () = { - const COMPRESSED_SIZE: usize = 8 + <#account_type as light_sdk::compressible::compression_info::CompressedInitSpace>::COMPRESSED_INIT_SPACE; - if COMPRESSED_SIZE > 800 { - panic!(concat!( - "Compressed account '", stringify!(#account_type), "' exceeds 800-byte compressible account size limit. If you need support for larger accounts, send a message to team@lightprotocol.com" - )); - } - }; - } - }).collect(); - - Ok(quote! { #(#size_checks)* }) -} - -#[inline(never)] -fn generate_error_codes(variant: InstructionVariant) -> Result { - let base_errors = quote! { - #[msg("Rent sponsor mismatch")] - InvalidRentSponsor, - #[msg("Missing seed account")] - MissingSeedAccount, - #[msg("Seed value does not match account data")] - SeedMismatch, - }; - - let variant_specific_errors = match variant { - InstructionVariant::PdaOnly => unreachable!(), - InstructionVariant::TokenOnly => unreachable!(), - InstructionVariant::Mixed => quote! { - #[msg("Not implemented")] - CTokenDecompressionNotImplemented, - #[msg("Not implemented")] - PdaDecompressionNotImplemented, - #[msg("Not implemented")] - TokenCompressionNotImplemented, - #[msg("Not implemented")] - PdaCompressionNotImplemented, - }, - }; - - Ok(quote! { - #[error_code] - pub enum CompressibleInstructionError { - #base_errors - #variant_specific_errors - } - }) -} - -/// Convert ClassifiedSeed to SeedElement (Punctuated) -fn convert_classified_to_seed_elements( - seeds: &[crate::compressible::anchor_seeds::ClassifiedSeed], -) -> Punctuated { - use crate::compressible::anchor_seeds::ClassifiedSeed; - - let mut result = Punctuated::new(); - for seed in seeds { - let elem = match seed { - ClassifiedSeed::Literal(bytes) => { - // Convert to string literal - if let Ok(s) = std::str::from_utf8(bytes) { - SeedElement::Literal(syn::LitStr::new(s, proc_macro2::Span::call_site())) - } else { - // Byte array - use expression - let byte_values: Vec<_> = bytes.iter().map(|b| quote!(#b)).collect(); - let expr: Expr = syn::parse_quote!(&[#(#byte_values),*]); - SeedElement::Expression(Box::new(expr)) - } - } - ClassifiedSeed::Constant(path) => { - let expr: Expr = syn::parse_quote!(#path); - SeedElement::Expression(Box::new(expr)) - } - ClassifiedSeed::CtxAccount(ident) => { - let expr: Expr = syn::parse_quote!(ctx.#ident); - SeedElement::Expression(Box::new(expr)) - } - ClassifiedSeed::DataField { - field_name, - conversion: None, - } => { - let expr: Expr = syn::parse_quote!(data.#field_name); - SeedElement::Expression(Box::new(expr)) - } - ClassifiedSeed::DataField { - field_name, - conversion: Some(method), - } => { - let expr: Expr = syn::parse_quote!(data.#field_name.#method()); - SeedElement::Expression(Box::new(expr)) - } - ClassifiedSeed::FunctionCall { func, ctx_args } => { - let args: Vec = ctx_args - .iter() - .map(|arg| syn::parse_quote!(&ctx.#arg.key())) - .collect(); - let expr: Expr = syn::parse_quote!(#func(#(#args),*)); - SeedElement::Expression(Box::new(expr)) - } - }; - result.push(elem); - } - result -} - -fn convert_classified_to_seed_elements_vec( - seeds: &[crate::compressible::anchor_seeds::ClassifiedSeed], -) -> Vec { - convert_classified_to_seed_elements(seeds) - .into_iter() - .collect() -} - -/// Generate all code from extracted seeds (shared logic with add_compressible_instructions) -#[inline(never)] -fn generate_from_extracted_seeds( - module: &mut ItemMod, - account_types: Vec, - pda_seeds: Option>, - token_seeds: Option>, - instruction_data: Vec, -) -> Result { - let size_validation_checks = validate_compressed_account_sizes(&account_types)?; - - let content = module.content.as_mut().unwrap(); - let ctoken_enum = if let Some(ref token_seed_specs) = token_seeds { - if !token_seed_specs.is_empty() { - crate::compressible::seed_providers::generate_ctoken_account_variant_enum( - token_seed_specs, - )? - } else { - crate::compressible::utils::generate_empty_ctoken_enum() - } - } else { - crate::compressible::utils::generate_empty_ctoken_enum() - }; - - if let Some(ref token_seed_specs) = token_seeds { - for spec in token_seed_specs { - if spec.authority.is_none() { - return Err(macro_error!( - &spec.variant, - "Token account '{}' must specify authority = for compression signing.", - spec.variant - )); - } - } - } - - let pda_ctx_seeds: Vec = pda_seeds - .as_ref() - .map(|specs| { - specs - .iter() - .map(|spec| { - let ctx_fields = extract_ctx_seed_fields(&spec.seeds); - crate::compressible::variant_enum::PdaCtxSeedInfo::new( - spec.variant.clone(), - ctx_fields, - ) - }) - .collect() - }) - .unwrap_or_default(); - - let account_type_refs: Vec<&Ident> = account_types.iter().collect(); - let enum_and_traits = - crate::compressible::variant_enum::compressed_account_variant_with_ctx_seeds( - &account_type_refs, - &pda_ctx_seeds, - )?; - - let seed_params_struct = quote! { - #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize, Clone, Debug, Default)] - pub struct SeedParams; - }; - - let instruction_data_types: std::collections::HashMap = instruction_data - .iter() - .map(|spec| (spec.field_name.to_string(), &spec.field_type)) - .collect(); - - let seeds_structs_and_constructors: Vec = if let Some(ref pda_seed_specs) = - pda_seeds - { - pda_seed_specs - .iter() - .zip(pda_ctx_seeds.iter()) - .map(|(spec, ctx_info)| { - let type_name = &spec.variant; - let seeds_struct_name = format_ident!("{}Seeds", type_name); - let constructor_name = format_ident!("{}", to_snake_case(&type_name.to_string())); - let ctx_fields = &ctx_info.ctx_seed_fields; - let ctx_field_decls: Vec<_> = ctx_fields.iter().map(|field| { - quote! { pub #field: solana_pubkey::Pubkey } - }).collect(); - let data_fields = extract_data_seed_fields(&spec.seeds); - let data_field_decls: Vec<_> = data_fields.iter().filter_map(|field| { - let field_str = field.to_string(); - instruction_data_types.get(&field_str).map(|ty| { - quote! { pub #field: #ty } - }) - }).collect(); - let data_verifications: Vec<_> = data_fields.iter().map(|field| { - quote! { - if data.#field != seeds.#field { - return std::result::Result::Err(CompressibleInstructionError::SeedMismatch.into()); - } - } - }).collect(); - quote! { - #[derive(Clone, Debug)] - pub struct #seeds_struct_name { - #(#ctx_field_decls,)* - #(#data_field_decls,)* - } - impl RentFreeAccountVariant { - pub fn #constructor_name( - account_data: &[u8], - seeds: #seeds_struct_name, - ) -> std::result::Result { - use anchor_lang::AnchorDeserialize; - let data = #type_name::deserialize(&mut &account_data[..])?; - - #(#data_verifications)* - - std::result::Result::Ok(Self::#type_name { - data, - #(#ctx_fields: seeds.#ctx_fields,)* - }) - } - } - impl light_sdk::compressible::IntoVariant for #seeds_struct_name { - fn into_variant(self, data: &[u8]) -> std::result::Result { - RentFreeAccountVariant::#constructor_name(data, self) - } - } - } - }) - .collect() - } else { - Vec::new() - }; - - let has_pda_seeds = pda_seeds.as_ref().map(|p| !p.is_empty()).unwrap_or(false); - let has_token_seeds = token_seeds.as_ref().map(|t| !t.is_empty()).unwrap_or(false); - - let instruction_variant = match (has_pda_seeds, has_token_seeds) { - (true, true) => InstructionVariant::Mixed, - (true, false) => InstructionVariant::PdaOnly, - (false, true) => InstructionVariant::TokenOnly, - (false, false) => { - return Err(macro_error!( - module, - "At least one PDA or token seed specification must be provided" - )) - } - }; - - let error_codes = generate_error_codes(instruction_variant)?; - let decompress_accounts = generate_decompress_accounts_struct(&[], instruction_variant)?; - - let pda_seed_provider_impls: Result> = account_types - .iter() - .zip(pda_ctx_seeds.iter()) - .map(|(name, ctx_info)| { - let name_str = name.to_string(); - let spec = if let Some(ref pda_seed_specs) = pda_seeds { - pda_seed_specs - .iter() - .find(|s| s.variant == name_str) - .ok_or_else(|| { - macro_error!(name, "No seed specification for account type '{}'", name_str) - })? - } else { - return Err(macro_error!(name, "No seed specifications provided")); - }; - - let ctx_seeds_struct_name = format_ident!("{}CtxSeeds", name); - let ctx_fields = &ctx_info.ctx_seed_fields; - let ctx_fields_decl: Vec<_> = ctx_fields.iter().map(|field| { - quote! { pub #field: solana_pubkey::Pubkey } - }).collect(); - - let ctx_seeds_struct = if ctx_fields.is_empty() { - quote! { - #[derive(Default)] - pub struct #ctx_seeds_struct_name; - } - } else { - quote! { - #[derive(Default)] - pub struct #ctx_seeds_struct_name { - #(#ctx_fields_decl),* - } - } - }; - - let seed_derivation = generate_pda_seed_derivation_for_trait_with_ctx_seeds(spec, &instruction_data, ctx_fields)?; - Ok(quote! { - #ctx_seeds_struct - - impl light_sdk::compressible::PdaSeedDerivation<#ctx_seeds_struct_name, ()> for #name { - fn derive_pda_seeds_with_accounts( - &self, - program_id: &solana_pubkey::Pubkey, - ctx_seeds: &#ctx_seeds_struct_name, - _seed_params: &(), - ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { - #seed_derivation - } - } - }) - }) - .collect(); - let pda_seed_provider_impls = pda_seed_provider_impls?; - - let trait_impls: syn::ItemMod = syn::parse_quote! { - mod __trait_impls { - use super::*; - - impl light_sdk::compressible::HasTokenVariant for RentFreeAccountData { - fn is_packed_token(&self) -> bool { - matches!(self.data, RentFreeAccountVariant::PackedCTokenData(_)) - } - } - } - }; - - let token_variant_name = format_ident!("TokenAccountVariant"); - - let decompress_context_impl = generate_decompress_context_impl( - instruction_variant, - pda_ctx_seeds.clone(), - token_variant_name, - )?; - let decompress_processor_fn = - generate_process_decompress_accounts_idempotent(instruction_variant, &instruction_data)?; - let decompress_instruction = - generate_decompress_instruction_entrypoint(instruction_variant, &instruction_data)?; - - let compress_accounts: syn::ItemStruct = match instruction_variant { - InstructionVariant::PdaOnly => unreachable!(), - InstructionVariant::TokenOnly => unreachable!(), - InstructionVariant::Mixed => syn::parse_quote! { - #[derive(Accounts)] - pub struct CompressAccountsIdempotent<'info> { - #[account(mut)] - pub fee_payer: Signer<'info>, - /// CHECK: Checked by SDK - pub config: AccountInfo<'info>, - /// CHECK: Checked by SDK - #[account(mut)] - pub rent_sponsor: AccountInfo<'info>, - /// CHECK: Checked by SDK - #[account(mut)] - pub compression_authority: AccountInfo<'info>, - } - }, - }; - - let compress_context_impl = - generate_compress_context_impl(instruction_variant, account_types.clone())?; - let compress_processor_fn = generate_process_compress_accounts_idempotent(instruction_variant)?; - let compress_instruction = generate_compress_instruction_entrypoint(instruction_variant)?; - - let module_tokens = quote! { - mod __processor_functions { - use super::*; - #decompress_processor_fn - #compress_processor_fn - } - }; - let processor_module: syn::ItemMod = syn::parse2(module_tokens)?; - - let init_config_accounts: syn::ItemStruct = syn::parse_quote! { - #[derive(Accounts)] - pub struct InitializeCompressionConfig<'info> { - #[account(mut)] - pub payer: Signer<'info>, - /// CHECK: Checked by SDK - #[account(mut)] - pub config: AccountInfo<'info>, - /// CHECK: Checked by SDK - pub program_data: AccountInfo<'info>, - pub authority: Signer<'info>, - pub system_program: Program<'info, System>, - } - }; - - let update_config_accounts: syn::ItemStruct = syn::parse_quote! { - #[derive(Accounts)] - pub struct UpdateCompressionConfig<'info> { - /// CHECK: Checked by SDK - #[account(mut)] - pub config: AccountInfo<'info>, - pub update_authority: Signer<'info>, - } - }; - - let init_config_instruction: syn::ItemFn = syn::parse_quote! { - #[inline(never)] - #[allow(clippy::too_many_arguments)] - pub fn initialize_compression_config<'info>( - ctx: Context<'_, '_, '_, 'info, InitializeCompressionConfig<'info>>, - write_top_up: u32, - rent_sponsor: Pubkey, - compression_authority: Pubkey, - rent_config: light_compressible::rent::RentConfig, - address_space: Vec, - ) -> Result<()> { - light_sdk::compressible::process_initialize_compression_config_checked( - &ctx.accounts.config.to_account_info(), - &ctx.accounts.authority.to_account_info(), - &ctx.accounts.program_data.to_account_info(), - &rent_sponsor, - &compression_authority, - rent_config, - write_top_up, - address_space, - 0, - &ctx.accounts.payer.to_account_info(), - &ctx.accounts.system_program.to_account_info(), - &crate::ID, - )?; - Ok(()) - } - }; - - let update_config_instruction: syn::ItemFn = syn::parse_quote! { - #[inline(never)] - #[allow(clippy::too_many_arguments)] - pub fn update_compression_config<'info>( - ctx: Context<'_, '_, '_, 'info, UpdateCompressionConfig<'info>>, - new_rent_sponsor: Option, - new_compression_authority: Option, - new_rent_config: Option, - new_write_top_up: Option, - new_address_space: Option>, - new_update_authority: Option, - ) -> Result<()> { - light_sdk::compressible::process_update_compression_config( - ctx.accounts.config.as_ref(), - ctx.accounts.update_authority.as_ref(), - new_update_authority.as_ref(), - new_rent_sponsor.as_ref(), - new_compression_authority.as_ref(), - new_rent_config, - new_write_top_up, - new_address_space, - &crate::ID, - )?; - Ok(()) - } - }; - - let client_functions = crate::compressible::seed_providers::generate_client_seed_functions( - &account_types, - &pda_seeds, - &token_seeds, - &instruction_data, - )?; - - // Insert SeedParams struct - let seed_params_item: Item = syn::parse2(seed_params_struct)?; - content.1.push(seed_params_item); - - // Insert XxxSeeds structs and RentFreeAccountVariant constructors - for seeds_tokens in seeds_structs_and_constructors.into_iter() { - let wrapped: syn::File = syn::parse2(seeds_tokens)?; - for item in wrapped.items { - content.1.push(item); - } - } - - content.1.push(Item::Verbatim(size_validation_checks)); - content.1.push(Item::Verbatim(enum_and_traits)); - content.1.push(Item::Verbatim(ctoken_enum)); - content.1.push(Item::Struct(decompress_accounts)); - content.1.push(Item::Mod(trait_impls)); - content.1.push(Item::Mod(decompress_context_impl)); - content.1.push(Item::Mod(processor_module)); - content.1.push(Item::Fn(decompress_instruction)); - content.1.push(Item::Struct(compress_accounts)); - content.1.push(Item::Mod(compress_context_impl)); - content.1.push(Item::Fn(compress_instruction)); - content.1.push(Item::Struct(init_config_accounts)); - content.1.push(Item::Struct(update_config_accounts)); - content.1.push(Item::Fn(init_config_instruction)); - content.1.push(Item::Fn(update_config_instruction)); - - // Add pda seed provider impls - for pda_impl in pda_seed_provider_impls.into_iter() { - let wrapped: syn::File = syn::parse2(pda_impl)?; - for item in wrapped.items { - content.1.push(item); - } - } - - // Add ctoken seed provider impl - if let Some(ref seeds) = token_seeds { - if !seeds.is_empty() { - let impl_code = - crate::compressible::seed_providers::generate_ctoken_seed_provider_implementation( - seeds, - )?; - let ctoken_impl: syn::ItemImpl = syn::parse2(impl_code)?; - content.1.push(Item::Impl(ctoken_impl)); - } - } - - // Add error codes - let error_item: syn::ItemEnum = syn::parse2(error_codes)?; - content.1.push(Item::Enum(error_item)); - - // Add client functions (module + pub use statement) - let client_file: syn::File = syn::parse2(client_functions)?; - for item in client_file.items { - content.1.push(item); - } - - Ok(quote! { #module }) -} - -// ============================================================================= -// COMPRESSIBLE_PROGRAM: Auto-discovers seeds from external module files -// ============================================================================= - -/// Main entry point for #[rentfree_program] macro. -/// -/// This macro reads external module files to extract seed information from -/// Accounts structs with #[rentfree] fields. It also automatically wraps -/// instruction handlers that use these Accounts structs with pre_init/finalize logic. -/// -/// Usage: -/// ```ignore -/// #[rentfree_program] -/// #[program] -/// pub mod my_program { -/// pub mod instruction_accounts; // Macro reads this file! -/// pub mod state; -/// -/// use instruction_accounts::*; -/// use state::*; -/// -/// // No #[light_instruction] needed - auto-wrapped! -/// pub fn create_user(ctx: Context, params: Params) -> Result<()> { -/// // Your business logic -/// } -/// } -/// ``` -/// Extract the Context type name from a function's parameters. -/// Returns (struct_name, params_ident) if found. -fn extract_context_and_params(fn_item: &syn::ItemFn) -> Option<(String, Ident)> { - let mut context_type = None; - let mut params_ident = None; - - for input in &fn_item.sig.inputs { - if let syn::FnArg::Typed(pat_type) = input { - if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { - // Check if this is a Context parameter - if let syn::Type::Path(type_path) = &*pat_type.ty { - if let Some(segment) = type_path.path.segments.last() { - if segment.ident == "Context" { - // Extract T from Context<'_, '_, '_, 'info, T<'info>> or Context - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - // Find the last type argument (T or T<'info>) - for arg in args.args.iter().rev() { - if let syn::GenericArgument::Type(syn::Type::Path(inner_path)) = - arg - { - if let Some(inner_seg) = inner_path.path.segments.last() { - context_type = Some(inner_seg.ident.to_string()); - break; - } - } - } - } - } - } - } - - // Track potential params argument (not ctx, not signer-like names) - let name = pat_ident.ident.to_string(); - if name != "ctx" && !name.contains("signer") && !name.contains("bump") { - // Prefer "params" but accept others - if name == "params" || params_ident.is_none() { - params_ident = Some(pat_ident.ident.clone()); - } - } - } - } - } - - match (context_type, params_ident) { - (Some(ctx), Some(params)) => Some((ctx, params)), - _ => None, - } -} - -/// Wrap a function with pre_init/finalize logic. -fn wrap_function_with_rentfree(fn_item: &syn::ItemFn, params_ident: &Ident) -> syn::ItemFn { - let fn_vis = &fn_item.vis; - let fn_sig = &fn_item.sig; - let fn_block = &fn_item.block; - let fn_attrs = &fn_item.attrs; - - let wrapped: syn::ItemFn = syn::parse_quote! { - #(#fn_attrs)* - #fn_vis #fn_sig { - // Phase 1: Pre-init (creates mints via CPI context write, registers compressed addresses) - use light_sdk::compressible::{LightPreInit, LightFinalize}; - let __has_pre_init = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) - .map_err(|e| { - let pe: solana_program_error::ProgramError = e.into(); - pe - })?; - - // Execute the original handler body in a closure - let __light_handler_result = (|| #fn_block)(); - - // Phase 2: On success, finalize compression - if __light_handler_result.is_ok() { - ctx.accounts.light_finalize(ctx.remaining_accounts, &#params_ident, __has_pre_init) - .map_err(|e| { - let pe: solana_program_error::ProgramError = e.into(); - pe - })?; - } - - __light_handler_result - } - }; - - wrapped -} - -#[inline(never)] -pub fn compressible_program_impl(_args: TokenStream, mut module: ItemMod) -> Result { - use crate::compressible::{ - anchor_seeds::get_data_fields, - file_scanner::{resolve_crate_src_path, scan_module_for_compressible}, - }; - - if module.content.is_none() { - return Err(macro_error!(&module, "Module must have a body")); - } - - // Resolve the crate's src/ directory - let base_path = resolve_crate_src_path(); - - // Scan the module (and external files) for compressible fields - let scanned = scan_module_for_compressible(&module, &base_path)?; - - // Report any errors from file scanning - if !scanned.errors.is_empty() { - let error_msg = scanned.errors.join("\n"); - return Err(macro_error!( - &module, - "Errors while scanning for rentfree types:\n{}", - error_msg - )); - } - - // Check if we found anything - if scanned.pda_specs.is_empty() && scanned.token_specs.is_empty() { - return Err(macro_error!( - &module, - "No #[rentfree] or #[rentfree_token] fields found in any Accounts struct.\n\ - Ensure your Accounts structs are in modules declared with `pub mod xxx;`" - )); - } - - // Auto-wrap instruction handlers that use rentfree Accounts structs - if let Some((_, ref mut items)) = module.content { - for item in items.iter_mut() { - if let Item::Fn(fn_item) = item { - // Check if this function uses a rentfree Accounts struct - if let Some((context_type, params_ident)) = extract_context_and_params(fn_item) { - if scanned.rentfree_struct_names.contains(&context_type) { - // Wrap the function with pre_init/finalize logic - *fn_item = wrap_function_with_rentfree(fn_item, ¶ms_ident); - } - } - } - } - } - - // Convert extracted specs to the format expected by generate_from_extracted_seeds - let mut found_pda_seeds: Vec = Vec::new(); - let mut found_data_fields: Vec = Vec::new(); - let mut account_types: Vec = Vec::new(); - - for pda in &scanned.pda_specs { - account_types.push(pda.inner_type.clone()); - - let seed_elements = convert_classified_to_seed_elements(&pda.seeds); - - // Extract data field types from seeds - for (field_name, conversion) in get_data_fields(&pda.seeds) { - let field_type: syn::Type = if conversion.is_some() { - syn::parse_quote!(u64) - } else { - syn::parse_quote!(solana_pubkey::Pubkey) - }; - - if !found_data_fields.iter().any(|f| f.field_name == field_name) { - found_data_fields.push(InstructionDataSpec { - field_name, - field_type, - }); - } - } - - found_pda_seeds.push(TokenSeedSpec { - variant: pda.variant_name.clone(), - _eq: syn::parse_quote!(=), - is_token: Some(false), - seeds: seed_elements, - authority: None, - }); - } - - // Convert token specs - let mut found_token_seeds: Vec = Vec::new(); - for token in &scanned.token_specs { - let seed_elements = convert_classified_to_seed_elements(&token.seeds); - let authority_elements = token - .authority_seeds - .as_ref() - .map(|seeds| convert_classified_to_seed_elements_vec(seeds)); - - found_token_seeds.push(TokenSeedSpec { - variant: token.variant_name.clone(), - _eq: syn::parse_quote!(=), - is_token: Some(true), - seeds: seed_elements, - authority: authority_elements, - }); - } - - let pda_seeds = if found_pda_seeds.is_empty() { - None - } else { - Some(found_pda_seeds) - }; - - let token_seeds = if found_token_seeds.is_empty() { - None - } else { - Some(found_token_seeds) - }; - - // Use the shared generation logic - generate_from_extracted_seeds( - &mut module, - account_types, - pda_seeds, - token_seeds, - found_data_fields, - ) -} diff --git a/sdk-libs/macros/src/compressible/mod.rs b/sdk-libs/macros/src/compressible/mod.rs deleted file mode 100644 index c02abdeb69..0000000000 --- a/sdk-libs/macros/src/compressible/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Compressible account macro generation. - -pub mod anchor_seeds; -pub mod decompress_context; -pub mod file_scanner; -pub mod instructions; -pub mod light_compressible; -pub mod pack_unpack; -pub mod seed_providers; -pub mod traits; -pub mod utils; -pub mod variant_enum; diff --git a/sdk-libs/macros/src/compressible/seed_providers.rs b/sdk-libs/macros/src/compressible/seed_providers.rs deleted file mode 100644 index b2d54de2cc..0000000000 --- a/sdk-libs/macros/src/compressible/seed_providers.rs +++ /dev/null @@ -1,964 +0,0 @@ -//! Seed provider generation for PDA and Light Token accounts. - -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{Ident, Result}; - -use crate::compressible::instructions::{InstructionDataSpec, SeedElement, TokenSeedSpec}; - -/// Extract ctx.* field names from seed elements (both token seeds and authority seeds) -fn extract_ctx_fields_from_token_spec(spec: &TokenSeedSpec) -> Vec { - let mut ctx_fields = Vec::new(); - let mut seen = std::collections::HashSet::new(); - - // Helper to extract ctx.* from a SeedElement - fn extract_from_seed( - seed: &SeedElement, - ctx_fields: &mut Vec, - seen: &mut std::collections::HashSet, - ) { - if let SeedElement::Expression(expr) = seed { - extract_ctx_from_expr(expr, ctx_fields, seen); - } - } - - fn extract_ctx_from_expr( - expr: &syn::Expr, - ctx_fields: &mut Vec, - seen: &mut std::collections::HashSet, - ) { - if let syn::Expr::Field(field_expr) = expr { - if let syn::Member::Named(field_name) = &field_expr.member { - // Check for ctx.accounts.field pattern - if let syn::Expr::Field(nested_field) = &*field_expr.base { - if let syn::Member::Named(base_name) = &nested_field.member { - if base_name == "accounts" { - if let syn::Expr::Path(path) = &*nested_field.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - let field_name_str = field_name.to_string(); - // Skip standard fields - if !matches!( - field_name_str.as_str(), - "fee_payer" - | "rent_sponsor" - | "config" - | "compression_authority" - ) && seen.insert(field_name_str) - { - ctx_fields.push(field_name.clone()); - } - } - } - } - } - } - } - // Check for ctx.field pattern (shorthand) - else if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - let field_name_str = field_name.to_string(); - if !matches!( - field_name_str.as_str(), - "fee_payer" | "rent_sponsor" | "config" | "compression_authority" - ) && seen.insert(field_name_str) - { - ctx_fields.push(field_name.clone()); - } - } - } - } - } - } - // Recursively check method calls like max_key(&ctx.field.key(), ...) - else if let syn::Expr::Call(call_expr) = expr { - for arg in &call_expr.args { - extract_ctx_from_expr(arg, ctx_fields, seen); - } - } else if let syn::Expr::Reference(ref_expr) = expr { - extract_ctx_from_expr(&ref_expr.expr, ctx_fields, seen); - } else if let syn::Expr::MethodCall(method_call) = expr { - extract_ctx_from_expr(&method_call.receiver, ctx_fields, seen); - } - } - - // Extract from seeds - for seed in &spec.seeds { - extract_from_seed(seed, &mut ctx_fields, &mut seen); - } - - // Extract from authority seeds too - if let Some(auth_seeds) = &spec.authority { - for seed in auth_seeds { - extract_from_seed(seed, &mut ctx_fields, &mut seen); - } - } - - ctx_fields -} - -pub fn generate_ctoken_account_variant_enum(token_seeds: &[TokenSeedSpec]) -> Result { - // Phase 8: Generate struct variants with ctx.* seed fields - - // Unpacked variants (with Pubkeys) - let unpacked_variants = 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,)* }, } - } - }); - - // Packed variants (with u8 indices) - let packed_variants = 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,)* }, } - } - }); - - // Pack impl match arms - let pack_arms = token_seeds.iter().map(|spec| { - let variant_name = &spec.variant; - let ctx_fields = extract_ctx_fields_from_token_spec(spec); - - if ctx_fields.is_empty() { - quote! { - TokenAccountVariant::#variant_name => PackedTokenAccountVariant::#variant_name, - } - } else { - let field_bindings: Vec<_> = ctx_fields.iter().collect(); - let idx_fields: Vec<_> = ctx_fields - .iter() - .map(|f| format_ident!("{}_idx", f)) - .collect(); - let pack_stmts: Vec<_> = ctx_fields - .iter() - .zip(idx_fields.iter()) - .map(|(field, idx)| { - quote! { let #idx = remaining_accounts.insert_or_get(*#field); } - }) - .collect(); - - quote! { - TokenAccountVariant::#variant_name { #(#field_bindings,)* } => { - #(#pack_stmts)* - PackedTokenAccountVariant::#variant_name { #(#idx_fields,)* } - } - } - } - }); - - // Unpack impl match arms - let unpack_arms = token_seeds.iter().map(|spec| { - let variant_name = &spec.variant; - let ctx_fields = extract_ctx_fields_from_token_spec(spec); - - if ctx_fields.is_empty() { - quote! { - PackedTokenAccountVariant::#variant_name => Ok(TokenAccountVariant::#variant_name), - } - } else { - let idx_fields: Vec<_> = ctx_fields - .iter() - .map(|f| format_ident!("{}_idx", f)) - .collect(); - let unpack_stmts: Vec<_> = ctx_fields - .iter() - .zip(idx_fields.iter()) - .map(|(field, idx)| { - // Dereference idx since match pattern gives us &u8 - quote! { - let #field = *remaining_accounts - .get(*#idx as usize) - .ok_or(solana_program_error::ProgramError::InvalidAccountData)? - .key; - } - }) - .collect(); - let field_names: Vec<_> = ctx_fields.iter().collect(); - - quote! { - PackedTokenAccountVariant::#variant_name { #(#idx_fields,)* } => { - #(#unpack_stmts)* - Ok(TokenAccountVariant::#variant_name { #(#field_names,)* }) - } - } - } - }); - - Ok(quote! { - #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] - pub enum TokenAccountVariant { - #(#unpacked_variants)* - } - - #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] - pub enum PackedTokenAccountVariant { - #(#packed_variants)* - } - - impl light_token_sdk::pack::Pack for TokenAccountVariant { - type Packed = PackedTokenAccountVariant; - - fn pack(&self, remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { - match self { - #(#pack_arms)* - } - } - } - - impl light_token_sdk::pack::Unpack for PackedTokenAccountVariant { - type Unpacked = TokenAccountVariant; - - fn unpack( - &self, - remaining_accounts: &[solana_account_info::AccountInfo], - ) -> std::result::Result { - match self { - #(#unpack_arms)* - } - } - } - - impl light_sdk::compressible::IntoCTokenVariant for TokenAccountVariant { - fn into_ctoken_variant(self, token_data: light_token_sdk::compat::TokenData) -> RentFreeAccountVariant { - RentFreeAccountVariant::CTokenData(light_token_sdk::compat::CTokenData { - variant: self, - token_data, - }) - } - } - }) -} - -/// Phase 8: Generate TokenSeedProvider impl that uses self.field instead of ctx.accounts.field -pub fn generate_ctoken_seed_provider_implementation( - token_seeds: &[TokenSeedSpec], -) -> Result { - let mut get_seeds_match_arms = Vec::new(); - let mut get_authority_seeds_match_arms = Vec::new(); - - for spec in token_seeds { - let variant_name = &spec.variant; - let ctx_fields = extract_ctx_fields_from_token_spec(spec); - - // Build match pattern with destructuring if there are ctx fields - let pattern = if ctx_fields.is_empty() { - quote! { TokenAccountVariant::#variant_name } - } else { - let field_names: Vec<_> = ctx_fields.iter().collect(); - quote! { TokenAccountVariant::#variant_name { #(#field_names,)* } } - }; - - // Build seed refs for get_seeds - use self.field directly for ctx.* seeds - let token_seed_refs: Vec = spec.seeds.iter().map(|seed| { - match seed { - SeedElement::Literal(lit) => { - let value = lit.value(); - quote! { #value.as_bytes() } - } - SeedElement::Expression(expr) => { - // Handle byte string literals - if let syn::Expr::Lit(lit_expr) = &**expr { - if let syn::Lit::ByteStr(byte_str) = &lit_expr.lit { - let bytes = byte_str.value(); - return quote! { &[#(#bytes),*] }; - } - } - - // Handle uppercase constants - if let syn::Expr::Path(path_expr) = &**expr { - if let Some(ident) = path_expr.path.get_ident() { - let ident_str = ident.to_string(); - if ident_str.chars().all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) { - if ident_str == "LIGHT_CPI_SIGNER" { - return quote! { crate::#ident.cpi_signer.as_ref() }; - } else { - return quote! { { let __seed: &[u8] = crate::#ident.as_ref(); __seed } }; - } - } - } - } - - // Handle ctx.accounts.field or ctx.field - use the destructured field directly - if let Some(field_name) = extract_ctx_field_name(expr) { - return quote! { #field_name.as_ref() }; - } - - // Fallback - quote! { (#expr).as_ref() } - } - } - }).collect(); - - let get_seeds_arm = quote! { - #pattern => { - let seeds: &[&[u8]] = &[#(#token_seed_refs),*]; - let (token_account_pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, program_id); - let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); - seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); - seeds_vec.push(vec![bump]); - Ok((seeds_vec, token_account_pda)) - } - }; - get_seeds_match_arms.push(get_seeds_arm); - - // Build authority seeds - if let Some(authority_seeds) = &spec.authority { - let auth_seed_refs: Vec = authority_seeds.iter().map(|seed| { - match seed { - SeedElement::Literal(lit) => { - let value = lit.value(); - quote! { #value.as_bytes() } - } - SeedElement::Expression(expr) => { - // Handle byte string literals - if let syn::Expr::Lit(lit_expr) = &**expr { - if let syn::Lit::ByteStr(byte_str) = &lit_expr.lit { - let bytes = byte_str.value(); - return quote! { &[#(#bytes),*] }; - } - } - - // Handle uppercase constants - if let syn::Expr::Path(path_expr) = &**expr { - if let Some(ident) = path_expr.path.get_ident() { - let ident_str = ident.to_string(); - if ident_str.chars().all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) { - if ident_str == "LIGHT_CPI_SIGNER" { - return quote! { crate::#ident.cpi_signer.as_ref() }; - } else { - return quote! { { let __seed: &[u8] = crate::#ident.as_ref(); __seed } }; - } - } - } - } - - // Handle ctx.accounts.field or ctx.field - use the destructured field directly - if let Some(field_name) = extract_ctx_field_name(expr) { - return quote! { #field_name.as_ref() }; - } - - // Fallback - quote! { (#expr).as_ref() } - } - } - }).collect(); - - let authority_arm = quote! { - #pattern => { - let seeds: &[&[u8]] = &[#(#auth_seed_refs),*]; - let (authority_pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, program_id); - let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); - seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); - seeds_vec.push(vec![bump]); - Ok((seeds_vec, authority_pda)) - } - }; - get_authority_seeds_match_arms.push(authority_arm); - } else { - let authority_arm = quote! { - #pattern => { - Err(solana_program_error::ProgramError::Custom( - CompressibleInstructionError::MissingSeedAccount.into() - )) - } - }; - get_authority_seeds_match_arms.push(authority_arm); - } - } - - // Phase 8: New trait signature - no ctx/accounts parameter needed - Ok(quote! { - impl light_sdk::compressible::TokenSeedProvider for TokenAccountVariant { - fn get_seeds( - &self, - program_id: &solana_pubkey::Pubkey, - ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { - match self { - #(#get_seeds_match_arms)* - } - } - - fn get_authority_seeds( - &self, - program_id: &solana_pubkey::Pubkey, - ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { - match self { - #(#get_authority_seeds_match_arms)* - } - } - } - }) -} - -/// Extract the field name from a ctx.field or ctx.accounts.field expression -fn extract_ctx_field_name(expr: &syn::Expr) -> Option { - if let syn::Expr::Field(field_expr) = expr { - if let syn::Member::Named(field_name) = &field_expr.member { - // Check for ctx.accounts.field pattern - if let syn::Expr::Field(nested_field) = &*field_expr.base { - if let syn::Member::Named(base_name) = &nested_field.member { - if base_name == "accounts" { - if let syn::Expr::Path(path) = &*nested_field.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - return Some(field_name.clone()); - } - } - } - } - } - } - // Check for ctx.field pattern (shorthand) - else if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - return Some(field_name.clone()); - } - } - } - } - } - None -} - -#[inline(never)] -pub fn generate_client_seed_functions( - _account_types: &[Ident], - pda_seeds: &Option>, - token_seeds: &Option>, - instruction_data: &[InstructionDataSpec], -) -> Result { - let mut functions = Vec::new(); - - if let Some(pda_seed_specs) = pda_seeds { - for spec in pda_seed_specs { - let variant_name = &spec.variant; - let snake_case = camel_to_snake_case(&variant_name.to_string()); - let function_name = format_ident!("get_{}_seeds", snake_case); - - let (parameters, seed_expressions) = - analyze_seed_spec_for_client(spec, instruction_data)?; - - let seed_count = seed_expressions.len(); - let function = quote! { - pub fn #function_name(#(#parameters),*) -> (Vec>, solana_pubkey::Pubkey) { - let mut seed_values = Vec::with_capacity(#seed_count + 1); - #( - seed_values.push((#seed_expressions).to_vec()); - )* - let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); - let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, &crate::ID); - seed_values.push(vec![bump]); - (seed_values, pda) - } - }; - functions.push(function); - } - } - - if let Some(token_seed_specs) = token_seeds { - for spec in token_seed_specs { - let variant_name = &spec.variant; - - let function_name = - format_ident!("get_{}_seeds", variant_name.to_string().to_lowercase()); - - let (parameters, seed_expressions) = - analyze_seed_spec_for_client(spec, instruction_data)?; - - let seed_count = seed_expressions.len(); - let function = quote! { - pub fn #function_name(#(#parameters),*) -> (Vec>, solana_pubkey::Pubkey) { - let mut seed_values = Vec::with_capacity(#seed_count + 1); - #( - seed_values.push((#seed_expressions).to_vec()); - )* - let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); - let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, &crate::ID); - seed_values.push(vec![bump]); - (seed_values, pda) - } - }; - functions.push(function); - - if let Some(authority_seeds) = &spec.authority { - let authority_function_name = format_ident!( - "get_{}_authority_seeds", - variant_name.to_string().to_lowercase() - ); - - let mut authority_spec = TokenSeedSpec { - variant: spec.variant.clone(), - _eq: spec._eq, - is_token: spec.is_token, - seeds: syn::punctuated::Punctuated::new(), - authority: None, - }; - - for auth_seed in authority_seeds { - authority_spec.seeds.push(auth_seed.clone()); - } - - let (auth_parameters, auth_seed_expressions) = - analyze_seed_spec_for_client(&authority_spec, instruction_data)?; - - let auth_seed_count = auth_seed_expressions.len(); - let (fn_params, fn_body) = if auth_parameters.is_empty() { - ( - quote! { _program_id: &solana_pubkey::Pubkey }, - quote! { - let mut seed_values = Vec::with_capacity(#auth_seed_count + 1); - #( - seed_values.push((#auth_seed_expressions).to_vec()); - )* - let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); - let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, _program_id); - seed_values.push(vec![bump]); - (seed_values, pda) - }, - ) - } else { - ( - quote! { #(#auth_parameters),* }, - quote! { - let mut seed_values = Vec::with_capacity(#auth_seed_count + 1); - #( - seed_values.push((#auth_seed_expressions).to_vec()); - )* - let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); - let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, &crate::ID); - seed_values.push(vec![bump]); - (seed_values, pda) - }, - ) - }; - let authority_function = quote! { - pub fn #authority_function_name(#fn_params) -> (Vec>, solana_pubkey::Pubkey) { - #fn_body - } - }; - functions.push(authority_function); - } - } - } - - Ok(quote! { - mod __client_seed_functions { - use super::*; - #(#functions)* - } - - pub use __client_seed_functions::*; - }) -} - -#[inline(never)] -fn analyze_seed_spec_for_client( - spec: &TokenSeedSpec, - instruction_data: &[InstructionDataSpec], -) -> Result<(Vec, Vec)> { - let mut parameters = Vec::new(); - let mut expressions = Vec::new(); - - for seed in &spec.seeds { - match seed { - SeedElement::Literal(lit) => { - let value = lit.value(); - expressions.push(quote! { #value.as_bytes() }); - } - SeedElement::Expression(expr) => { - match &**expr { - syn::Expr::Field(field_expr) => { - if let syn::Member::Named(field_name) = &field_expr.member { - match &*field_expr.base { - syn::Expr::Field(nested_field) => { - if let syn::Member::Named(base_name) = &nested_field.member { - if base_name == "accounts" { - if let syn::Expr::Path(path) = &*nested_field.base { - if let Some(_segment) = path.path.segments.first() { - parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); - expressions - .push(quote! { #field_name.as_ref() }); - } else { - parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); - expressions - .push(quote! { #field_name.as_ref() }); - } - } else { - parameters.push( - quote! { #field_name: &solana_pubkey::Pubkey }, - ); - expressions.push(quote! { #field_name.as_ref() }); - } - } else { - parameters.push( - quote! { #field_name: &solana_pubkey::Pubkey }, - ); - expressions.push(quote! { #field_name.as_ref() }); - } - } else { - parameters - .push(quote! { #field_name: &solana_pubkey::Pubkey }); - expressions.push(quote! { #field_name.as_ref() }); - } - } - syn::Expr::Path(path) => { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "data" { - if let Some(data_spec) = instruction_data - .iter() - .find(|d| d.field_name == *field_name) - { - let param_type = &data_spec.field_type; - let param_with_ref = if is_pubkey_type(param_type) { - quote! { #field_name: &#param_type } - } else { - quote! { #field_name: #param_type } - }; - parameters.push(param_with_ref); - expressions.push(quote! { #field_name.as_ref() }); - } else { - return Err(syn::Error::new_spanned( - field_name, - format!("data.{} used in seeds but no type specified", field_name), - )); - } - } else { - parameters.push( - quote! { #field_name: &solana_pubkey::Pubkey }, - ); - expressions.push(quote! { #field_name.as_ref() }); - } - } else { - parameters - .push(quote! { #field_name: &solana_pubkey::Pubkey }); - expressions.push(quote! { #field_name.as_ref() }); - } - } - _ => { - parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); - expressions.push(quote! { #field_name.as_ref() }); - } - } - } - } - syn::Expr::MethodCall(method_call) => { - if let syn::Expr::Field(field_expr) = &*method_call.receiver { - if let syn::Member::Named(field_name) = &field_expr.member { - if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "data" { - if let Some(data_spec) = instruction_data - .iter() - .find(|d| d.field_name == *field_name) - { - let param_type = &data_spec.field_type; - let param_with_ref = if is_pubkey_type(param_type) { - quote! { #field_name: &#param_type } - } else { - quote! { #field_name: #param_type } - }; - parameters.push(param_with_ref); - - let method_name = &method_call.method; - expressions.push( - quote! { #field_name.#method_name().as_ref() }, - ); - } else { - return Err(syn::Error::new_spanned( - field_name, - format!("data.{} used in seeds but no type specified", field_name), - )); - } - } else if segment.ident == "ctx" { - // ctx.field.method() -> add field as Pubkey parameter - parameters.push( - quote! { #field_name: &solana_pubkey::Pubkey }, - ); - let method_name = &method_call.method; - expressions.push( - quote! { #field_name.#method_name().as_ref() }, - ); - } - } - } - } - } else if let syn::Expr::Path(path_expr) = &*method_call.receiver { - if let Some(ident) = path_expr.path.get_ident() { - parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); - expressions.push(quote! { #ident.as_ref() }); - } - } - } - syn::Expr::Lit(lit_expr) => { - // Handle byte string literals: b"seed" -> use directly - if let syn::Lit::ByteStr(byte_str) = &lit_expr.lit { - let bytes = byte_str.value(); - expressions.push(quote! { &[#(#bytes),*] }); - } - } - syn::Expr::Path(path_expr) => { - if let Some(ident) = path_expr.path.get_ident() { - let ident_str = ident.to_string(); - if ident_str - .chars() - .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) - { - if ident_str == "LIGHT_CPI_SIGNER" { - expressions.push(quote! { crate::#ident.cpi_signer.as_ref() }); - } else { - // Use crate:: prefix and explicit type annotation - expressions.push(quote! { { let __seed: &[u8] = crate::#ident.as_ref(); __seed } }); - } - } else { - parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); - expressions.push(quote! { #ident.as_ref() }); - } - } else { - expressions.push(quote! { (#expr).as_ref() }); - } - } - syn::Expr::Call(call_expr) => { - // Recursively map data.* to parameter names in function call arguments - fn map_client_call_arg( - arg: &syn::Expr, - instruction_data: &[InstructionDataSpec], - parameters: &mut Vec, - ) -> TokenStream { - match arg { - syn::Expr::Reference(ref_expr) => { - let inner = map_client_call_arg( - &ref_expr.expr, - instruction_data, - parameters, - ); - quote! { &#inner } - } - syn::Expr::Field(field_expr) => { - if let syn::Member::Named(field_name) = &field_expr.member { - if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "data" { - // Add parameter if needed - if let Some(data_spec) = instruction_data - .iter() - .find(|d| d.field_name == *field_name) - { - let param_type = &data_spec.field_type; - let param_with_ref = - if is_pubkey_type(param_type) { - quote! { #field_name: &#param_type } - } else { - quote! { #field_name: #param_type } - }; - if !parameters.iter().any(|p| { - p.to_string() - .contains(&field_name.to_string()) - }) { - parameters.push(param_with_ref); - } - } - return quote! { #field_name }; - } else if segment.ident == "ctx" { - // ctx.field -> add as Pubkey parameter - if !parameters.iter().any(|p| { - p.to_string() - .contains(&field_name.to_string()) - }) { - parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); - } - return quote! { #field_name }; - } - } - } - } - quote! { #field_expr } - } - syn::Expr::MethodCall(method_call) => { - let receiver = map_client_call_arg( - &method_call.receiver, - instruction_data, - parameters, - ); - let method = &method_call.method; - let args: Vec<_> = method_call - .args - .iter() - .map(|a| { - map_client_call_arg(a, instruction_data, parameters) - }) - .collect(); - quote! { (#receiver).#method(#(#args),*) } - } - syn::Expr::Call(nested_call) => { - let func = &nested_call.func; - let args: Vec<_> = nested_call - .args - .iter() - .map(|a| { - map_client_call_arg(a, instruction_data, parameters) - }) - .collect(); - quote! { (#func)(#(#args),*) } - } - _ => quote! { #arg }, - } - } - - let mut mapped_args: Vec = Vec::new(); - for arg in &call_expr.args { - let mapped = - map_client_call_arg(arg, instruction_data, &mut parameters); - mapped_args.push(mapped); - } - let func = &call_expr.func; - expressions.push(quote! { (#func)(#(#mapped_args),*).as_ref() }); - } - syn::Expr::Reference(ref_expr) => { - let (ref_params, ref_exprs) = - analyze_seed_spec_for_client_expr(&ref_expr.expr, instruction_data)?; - parameters.extend(ref_params); - if let Some(first_expr) = ref_exprs.first() { - expressions.push(quote! { (#first_expr).as_ref() }); - } - } - _ => { - expressions.push(quote! { (#expr).as_ref() }); - } - } - } - } - } - - Ok((parameters, expressions)) -} - -#[inline(never)] -fn analyze_seed_spec_for_client_expr( - expr: &syn::Expr, - instruction_data: &[InstructionDataSpec], -) -> Result<(Vec, Vec)> { - let mut parameters = Vec::new(); - let mut expressions = Vec::new(); - - match expr { - syn::Expr::Field(field_expr) => { - if let syn::Member::Named(field_name) = &field_expr.member { - if let syn::Expr::Field(nested_field) = &*field_expr.base { - if let syn::Member::Named(base_name) = &nested_field.member { - if base_name == "accounts" { - parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); - expressions.push(quote! { #field_name }); - } else if base_name == "data" { - // Use declared instruction_data types to determine parameter type - if let Some(data_spec) = instruction_data - .iter() - .find(|d| d.field_name == *field_name) - { - let param_type = &data_spec.field_type; - let param_with_ref = if is_pubkey_type(param_type) { - quote! { #field_name: &#param_type } - } else { - quote! { #field_name: #param_type } - }; - parameters.push(param_with_ref); - expressions.push(quote! { #field_name }); - } else { - return Err(syn::Error::new_spanned( - field_name, - format!( - "data.{} used in seeds but no type specified", - field_name - ), - )); - } - } - } - } else if let syn::Expr::Path(path) = &*field_expr.base { - if let Some(segment) = path.path.segments.first() { - if segment.ident == "ctx" { - parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); - expressions.push(quote! { #field_name }); - } - } - } - } - } - syn::Expr::MethodCall(method_call) => { - let (recv_params, _) = - analyze_seed_spec_for_client_expr(&method_call.receiver, instruction_data)?; - parameters.extend(recv_params); - } - syn::Expr::Call(call_expr) => { - for arg in &call_expr.args { - let (arg_params, _) = analyze_seed_spec_for_client_expr(arg, instruction_data)?; - parameters.extend(arg_params); - } - } - syn::Expr::Reference(ref_expr) => { - let (ref_params, _) = - analyze_seed_spec_for_client_expr(&ref_expr.expr, instruction_data)?; - parameters.extend(ref_params); - } - syn::Expr::Path(path_expr) => { - if let Some(ident) = path_expr.path.get_ident() { - let name = ident.to_string(); - if !(name == "ctx" - || name == "data" - || name - .chars() - .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit())) - { - parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); - } - } - } - _ => {} - } - - Ok((parameters, expressions)) -} - -fn camel_to_snake_case(s: &str) -> String { - let mut result = String::new(); - for (i, c) in s.chars().enumerate() { - if c.is_uppercase() && i > 0 { - result.push('_'); - } - result.push(c.to_lowercase().next().unwrap()); - } - result -} - -fn is_pubkey_type(ty: &syn::Type) -> bool { - if let syn::Type::Path(type_path) = ty { - if let Some(segment) = type_path.path.segments.last() { - let type_name = segment.ident.to_string(); - type_name == "Pubkey" || type_name.contains("Pubkey") - } else { - false - } - } else { - false - } -} diff --git a/sdk-libs/macros/src/finalize/codegen.rs b/sdk-libs/macros/src/finalize/codegen.rs deleted file mode 100644 index 0e8059c3c6..0000000000 --- a/sdk-libs/macros/src/finalize/codegen.rs +++ /dev/null @@ -1,685 +0,0 @@ -//! Code generation for LightFinalize and LightPreInit trait implementations. -//! -//! Design for mints: -//! - At mint init, we CREATE + DECOMPRESS atomically -//! - After init, the CMint should always be in decompressed/"hot" state -//! -//! Flow for PDAs + mints: -//! 1. Pre-init: ALL compression logic executes here -//! a. Write PDAs to CPI context -//! b. Invoke mint_action with decompress + CPI context -//! c. CMint is now "hot" and usable -//! 2. Instruction body: Can use hot CMint (mintTo, transfers, etc.) -//! 3. Finalize: No-op (all work done in pre_init) - -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; - -use super::parse::{ParsedCompressibleStruct, RentFreeField}; - -/// Generate both trait implementations -pub fn generate_finalize_impl(parsed: &ParsedCompressibleStruct) -> TokenStream { - let struct_name = &parsed.struct_name; - let (impl_generics, ty_generics, where_clause) = parsed.generics.split_for_impl(); - - // Get the params type from instruction args (first arg) - let params_type = parsed - .instruction_args - .as_ref() - .and_then(|args| args.first()) - .map(|arg| &arg.ty); - - let params_type = match params_type { - Some(ty) => ty, - None => { - // No instruction args - generate no-op impls - return quote! { - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightPreInit<'info, ()> for #struct_name #ty_generics #where_clause { - fn light_pre_init( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - _params: &(), - ) -> std::result::Result { - Ok(false) - } - } - - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightFinalize<'info, ()> for #struct_name #ty_generics #where_clause { - fn light_finalize( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - _params: &(), - _has_pre_init: bool, - ) -> std::result::Result<(), light_sdk::error::LightSdkError> { - Ok(()) - } - } - }; - } - }; - - let params_ident = parsed - .instruction_args - .as_ref() - .and_then(|args| args.first()) - .map(|arg| &arg.name) - .expect("params ident must exist if type exists"); - - let has_pdas = !parsed.rentfree_fields.is_empty(); - let has_mints = !parsed.light_mint_fields.is_empty(); - - // Get fee payer field - let fee_payer = parsed - .fee_payer_field - .as_ref() - .map(|f| quote! { #f }) - .unwrap_or_else(|| quote! { fee_payer }); - - let compression_config = parsed - .compression_config_field - .as_ref() - .map(|f| quote! { #f }) - .unwrap_or_else(|| quote! { compression_config }); - - // CToken accounts for decompress - let ctoken_config = parsed - .ctoken_config_field - .as_ref() - .map(|f| quote! { #f }) - .unwrap_or_else(|| quote! { ctoken_compressible_config }); - - let ctoken_rent_sponsor = parsed - .ctoken_rent_sponsor_field - .as_ref() - .map(|f| quote! { #f }) - .unwrap_or_else(|| quote! { ctoken_rent_sponsor }); - - let light_token_program = parsed - .ctoken_program_field - .as_ref() - .map(|f| quote! { #f }) - .unwrap_or_else(|| quote! { light_token_program }); - - let ctoken_cpi_authority = parsed - .ctoken_cpi_authority_field - .as_ref() - .map(|f| quote! { #f }) - .unwrap_or_else(|| quote! { ctoken_cpi_authority }); - - // Generate LightPreInit impl based on what we have - // ALL compression logic runs in pre_init so instruction body can use hot state - let pre_init_body = if has_pdas && has_mints { - // PDAs + mints: Write PDAs to CPI context, then invoke mint_action with decompress - generate_pre_init_pdas_and_mints( - parsed, - params_ident, - &fee_payer, - &compression_config, - &ctoken_config, - &ctoken_rent_sponsor, - &light_token_program, - &ctoken_cpi_authority, - ) - } else if has_mints { - // Mints only: Invoke mint_action with decompress (no CPI context) - generate_pre_init_mints_only( - parsed, - params_ident, - &fee_payer, - &ctoken_config, - &ctoken_rent_sponsor, - &light_token_program, - &ctoken_cpi_authority, - ) - } else if has_pdas { - // PDAs only: Direct invoke (no CPI context needed) - generate_pre_init_pdas_only(parsed, params_ident, &fee_payer, &compression_config) - } else { - quote! { Ok(false) } - }; - - // LightFinalize: No-op (all work done in pre_init) - let finalize_body = quote! { Ok(()) }; - - quote! { - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightPreInit<'info, #params_type> for #struct_name #ty_generics #where_clause { - fn light_pre_init( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - #params_ident: &#params_type, - ) -> std::result::Result { - use anchor_lang::ToAccountInfo; - #pre_init_body - } - } - - #[automatically_derived] - impl #impl_generics light_sdk::compressible::LightFinalize<'info, #params_type> for #struct_name #ty_generics #where_clause { - fn light_finalize( - &mut self, - _remaining: &[solana_account_info::AccountInfo<'info>], - #params_ident: &#params_type, - _has_pre_init: bool, - ) -> std::result::Result<(), light_sdk::error::LightSdkError> { - use anchor_lang::ToAccountInfo; - #finalize_body - } - } - } -} - -/// Generate LightPreInit body for PDAs + mints: -/// 1. Write PDAs to CPI context -/// 2. Invoke mint_action with decompress + CPI context -/// After this, Mint is "hot" and usable in instruction body -#[allow(clippy::too_many_arguments)] -fn generate_pre_init_pdas_and_mints( - parsed: &ParsedCompressibleStruct, - params_ident: &syn::Ident, - fee_payer: &TokenStream, - compression_config: &TokenStream, - ctoken_config: &TokenStream, - ctoken_rent_sponsor: &TokenStream, - light_token_program: &TokenStream, - ctoken_cpi_authority: &TokenStream, -) -> TokenStream { - let (compress_blocks, new_addr_idents) = - generate_pda_compress_blocks(&parsed.rentfree_fields, params_ident); - let rentfree_count = parsed.rentfree_fields.len() as u8; - let pda_count = parsed.rentfree_fields.len(); - - // Get the first PDA's output tree index (for the state tree output queue) - let first_pda_output_tree = &parsed.rentfree_fields[0].output_tree; - - // Get the first mint (we only support one mint currently) - let mint = &parsed.light_mint_fields[0]; - let mint_field_ident = &mint.field_ident; - let mint_signer = &mint.mint_signer; - let authority = &mint.authority; - let decimals = &mint.decimals; - let address_tree_info = &mint.address_tree_info; - - // Use explicit signer_seeds if provided, otherwise empty - let signer_seeds_tokens = if let Some(seeds) = &mint.signer_seeds { - quote! { #seeds } - } else { - quote! { &[] as &[&[u8]] } - }; - - // Build freeze_authority expression - let freeze_authority_tokens = if let Some(freeze_auth) = &mint.freeze_authority { - quote! { Some(*self.#freeze_auth.to_account_info().key) } - } else { - quote! { None } - }; - - // rent_payment defaults to 2 epochs (u8) - let rent_payment_tokens = if let Some(rent) = &mint.rent_payment { - quote! { #rent } - } else { - quote! { 2u8 } - }; - - // write_top_up defaults to 0 (u32) - let write_top_up_tokens = if let Some(top_up) = &mint.write_top_up { - quote! { #top_up } - } else { - quote! { 0u32 } - }; - - // assigned_account_index for mint is after PDAs - let mint_assigned_index = pda_count as u8; - - quote! { - // Build CPI accounts WITH CPI context for batching - let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new_with_config( - &self.#fee_payer, - _remaining, - light_sdk_types::cpi_accounts::CpiAccountsConfig::new_with_cpi_context(crate::LIGHT_CPI_SIGNER), - ); - - // Load compression config - let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( - &self.#compression_config, - &crate::ID - )?; - - // Collect compressed infos for all rentfree PDA accounts - let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); - #(#compress_blocks)* - - // Step 1: Write PDAs to CPI context - let cpi_context_account = cpi_accounts.cpi_context()?; - let cpi_context_accounts = light_sdk_types::cpi_context_write::CpiContextWriteAccounts { - fee_payer: cpi_accounts.fee_payer(), - authority: cpi_accounts.authority()?, - cpi_context: cpi_context_account, - cpi_signer: crate::LIGHT_CPI_SIGNER, - }; - - use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; - light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( - crate::LIGHT_CPI_SIGNER, - #params_ident.create_accounts_proof.proof.clone() - ) - .with_new_addresses(&[#(#new_addr_idents),*]) - .with_account_infos(&all_compressed_infos) - .write_to_cpi_context_first() - .invoke_write_to_cpi_context_first(cpi_context_accounts)?; - - // Step 2: Build and invoke mint_action with decompress + CPI context - { - let __tree_info = &#address_tree_info; - let address_tree = cpi_accounts.get_tree_account_info(__tree_info.address_merkle_tree_pubkey_index as usize)?; - // Output queue is the state tree queue (same as the PDAs' output tree) - let __output_tree_index = #first_pda_output_tree; - let output_queue = cpi_accounts.get_tree_account_info(__output_tree_index as usize)?; - let __tree_pubkey: solana_pubkey::Pubkey = light_sdk::light_account_checks::AccountInfoTrait::pubkey(address_tree); - - let mint_signer_key = self.#mint_signer.to_account_info().key; - let (mint_pda, _cmint_bump) = light_token_sdk::token::find_mint_address(mint_signer_key); - - let __proof: light_token_sdk::CompressedProof = #params_ident.create_accounts_proof.proof.0.clone() - .expect("proof is required for mint creation"); - - let __freeze_authority: Option = #freeze_authority_tokens; - - // Build compressed mint instruction data - let compressed_mint_data = light_token_interface::instructions::mint_action::MintInstructionData { - supply: 0, - decimals: #decimals, - metadata: light_token_interface::state::MintMetadata { - version: 3, - mint: mint_pda.to_bytes().into(), - mint_decompressed: false, - mint_signer: mint_signer_key.to_bytes(), - bump: _cmint_bump, - }, - mint_authority: Some((*self.#authority.to_account_info().key).to_bytes().into()), - freeze_authority: __freeze_authority.map(|a| a.to_bytes().into()), - extensions: None, - }; - - // Build mint action instruction data with decompress - let mut instruction_data = light_token_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - __tree_info.root_index, - __proof, - compressed_mint_data, - ) - .with_decompress_mint(light_token_interface::instructions::mint_action::DecompressMintAction { - rent_payment: #rent_payment_tokens, - write_top_up: #write_top_up_tokens, - }) - .with_cpi_context(light_token_interface::instructions::mint_action::CpiContext { - address_tree_pubkey: __tree_pubkey.to_bytes(), - set_context: false, - first_set_context: false, // PDAs already wrote to context - // in_tree_index is 1-indexed and points to the state queue (for CPI context validation) - // The Light System Program does `in_tree_index - 1` and uses queue's associated_merkle_tree - in_tree_index: __output_tree_index + 1, // +1 because 1-indexed - in_queue_index: __output_tree_index, - out_queue_index: __output_tree_index, // Output state queue - token_out_queue_index: 0, - assigned_account_index: #mint_assigned_index, - read_only_address_trees: [0; 4], - }); - - // Build account metas with compressible CMint - let mut meta_config = light_token_sdk::compressed_token::mint_action::MintActionMetaConfig::new_create_mint( - *self.#fee_payer.to_account_info().key, - *self.#authority.to_account_info().key, - *mint_signer_key, - __tree_pubkey, - *output_queue.key, - ) - .with_compressible_mint( - mint_pda, - *self.#ctoken_config.to_account_info().key, - *self.#ctoken_rent_sponsor.to_account_info().key, - ); - - meta_config.cpi_context = Some(*cpi_accounts.cpi_context()?.key); - - let account_metas = meta_config.to_account_metas(); - - use light_compressed_account::instruction_data::traits::LightInstructionData; - let ix_data = instruction_data.data() - .map_err(|e| light_sdk::error::LightSdkError::Borsh)?; - - let mint_action_ix = anchor_lang::solana_program::instruction::Instruction { - program_id: solana_pubkey::Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID), - accounts: account_metas, - data: ix_data, - }; - - // Build account infos and invoke - // Include all accounts needed for mint_action with decompress - let mut account_infos = cpi_accounts.to_account_infos(); - // Add ctoken-specific accounts that aren't in the Light System CPI accounts - account_infos.push(self.#light_token_program.to_account_info()); - account_infos.push(self.#ctoken_cpi_authority.to_account_info()); - account_infos.push(self.#mint_field_ident.to_account_info()); - account_infos.push(self.#ctoken_config.to_account_info()); - account_infos.push(self.#ctoken_rent_sponsor.to_account_info()); - account_infos.push(self.#authority.to_account_info()); - account_infos.push(self.#mint_signer.to_account_info()); - account_infos.push(self.#fee_payer.to_account_info()); - - let signer_seeds: &[&[u8]] = #signer_seeds_tokens; - if signer_seeds.is_empty() { - anchor_lang::solana_program::program::invoke(&mint_action_ix, &account_infos)?; - } else { - anchor_lang::solana_program::program::invoke_signed(&mint_action_ix, &account_infos, &[signer_seeds])?; - } - } - - Ok(true) - } -} - -/// Generate LightPreInit body for mints-only (no PDAs): -/// Invoke mint_action with decompress directly -/// After this, CMint is "hot" and usable in instruction body -#[allow(clippy::too_many_arguments)] -fn generate_pre_init_mints_only( - parsed: &ParsedCompressibleStruct, - params_ident: &syn::Ident, - fee_payer: &TokenStream, - ctoken_config: &TokenStream, - ctoken_rent_sponsor: &TokenStream, - light_token_program: &TokenStream, - ctoken_cpi_authority: &TokenStream, -) -> TokenStream { - // Get the first mint (we only support one mint currently) - let mint = &parsed.light_mint_fields[0]; - let mint_field_ident = &mint.field_ident; - let mint_signer = &mint.mint_signer; - let authority = &mint.authority; - let decimals = &mint.decimals; - let address_tree_info = &mint.address_tree_info; - - // Use explicit signer_seeds if provided, otherwise empty - let signer_seeds_tokens = if let Some(seeds) = &mint.signer_seeds { - quote! { #seeds } - } else { - quote! { &[] as &[&[u8]] } - }; - - // Build freeze_authority expression - let freeze_authority_tokens = if let Some(freeze_auth) = &mint.freeze_authority { - quote! { Some(*self.#freeze_auth.to_account_info().key) } - } else { - quote! { None } - }; - - // rent_payment defaults to 2 epochs (u8) - let rent_payment_tokens = if let Some(rent) = &mint.rent_payment { - quote! { #rent } - } else { - quote! { 2u8 } - }; - - // write_top_up defaults to 0 (u32) - let write_top_up_tokens = if let Some(top_up) = &mint.write_top_up { - quote! { #top_up } - } else { - quote! { 0u32 } - }; - - quote! { - // Build CPI accounts (no CPI context needed for mints-only) - let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( - &self.#fee_payer, - _remaining, - crate::LIGHT_CPI_SIGNER, - ); - - // Build and invoke mint_action with decompress - { - let __tree_info = &#address_tree_info; - let address_tree = cpi_accounts.get_tree_account_info(__tree_info.address_merkle_tree_pubkey_index as usize)?; - let output_queue = cpi_accounts.get_tree_account_info(__tree_info.address_queue_pubkey_index as usize)?; - let __tree_pubkey: solana_pubkey::Pubkey = light_sdk::light_account_checks::AccountInfoTrait::pubkey(address_tree); - - let mint_signer_key = self.#mint_signer.to_account_info().key; - let (mint_pda, _cmint_bump) = light_token_sdk::token::find_mint_address(mint_signer_key); - - let __proof: light_token_sdk::CompressedProof = #params_ident.create_accounts_proof.proof.0.clone() - .expect("proof is required for mint creation"); - - let __freeze_authority: Option = #freeze_authority_tokens; - - // Build compressed mint instruction data - let compressed_mint_data = light_token_interface::instructions::mint_action::MintInstructionData { - supply: 0, - decimals: #decimals, - metadata: light_token_interface::state::MintMetadata { - version: 3, - mint: mint_pda.to_bytes().into(), - mint_decompressed: false, - mint_signer: mint_signer_key.to_bytes(), - bump: _cmint_bump, - }, - mint_authority: Some((*self.#authority.to_account_info().key).to_bytes().into()), - freeze_authority: __freeze_authority.map(|a| a.to_bytes().into()), - extensions: None, - }; - - // Build mint action instruction data with decompress (no CPI context) - let instruction_data = light_token_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( - __tree_info.root_index, - __proof, - compressed_mint_data, - ) - .with_decompress_mint(light_token_interface::instructions::mint_action::DecompressMintAction { - rent_payment: #rent_payment_tokens, - write_top_up: #write_top_up_tokens, - }); - - // Build account metas with compressible CMint - let meta_config = light_token_sdk::compressed_token::mint_action::MintActionMetaConfig::new_create_mint( - *self.#fee_payer.to_account_info().key, - *self.#authority.to_account_info().key, - *mint_signer_key, - __tree_pubkey, - *output_queue.key, - ) - .with_compressible_mint( - mint_pda, - *self.#ctoken_config.to_account_info().key, - *self.#ctoken_rent_sponsor.to_account_info().key, - ); - - let account_metas = meta_config.to_account_metas(); - - use light_compressed_account::instruction_data::traits::LightInstructionData; - let ix_data = instruction_data.data() - .map_err(|e| light_sdk::error::LightSdkError::Borsh)?; - - let mint_action_ix = anchor_lang::solana_program::instruction::Instruction { - program_id: solana_pubkey::Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID), - accounts: account_metas, - data: ix_data, - }; - - // Build account infos and invoke - let mut account_infos = cpi_accounts.to_account_infos(); - // Add ctoken-specific accounts - account_infos.push(self.#light_token_program.to_account_info()); - account_infos.push(self.#ctoken_cpi_authority.to_account_info()); - account_infos.push(self.#mint_field_ident.to_account_info()); - account_infos.push(self.#ctoken_config.to_account_info()); - account_infos.push(self.#ctoken_rent_sponsor.to_account_info()); - account_infos.push(self.#authority.to_account_info()); - account_infos.push(self.#mint_signer.to_account_info()); - account_infos.push(self.#fee_payer.to_account_info()); - - let signer_seeds: &[&[u8]] = #signer_seeds_tokens; - if signer_seeds.is_empty() { - anchor_lang::solana_program::program::invoke(&mint_action_ix, &account_infos)?; - } else { - anchor_lang::solana_program::program::invoke_signed(&mint_action_ix, &account_infos, &[signer_seeds])?; - } - } - - Ok(true) - } -} - -/// Generate LightPreInit body for PDAs only (no mints) -/// After this, compressed addresses are registered -fn generate_pre_init_pdas_only( - parsed: &ParsedCompressibleStruct, - params_ident: &syn::Ident, - fee_payer: &TokenStream, - compression_config: &TokenStream, -) -> TokenStream { - let (compress_blocks, new_addr_idents) = - generate_pda_compress_blocks(&parsed.rentfree_fields, params_ident); - let rentfree_count = parsed.rentfree_fields.len() as u8; - - quote! { - // Build CPI accounts (no CPI context needed for PDAs-only) - let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( - &self.#fee_payer, - _remaining, - crate::LIGHT_CPI_SIGNER, - ); - - // Load compression config - let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( - &self.#compression_config, - &crate::ID - )?; - - // Collect compressed infos for all rentfree accounts - let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); - #(#compress_blocks)* - - // Execute Light System Program CPI directly with proof - use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; - light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( - crate::LIGHT_CPI_SIGNER, - #params_ident.create_accounts_proof.proof.clone() - ) - .with_new_addresses(&[#(#new_addr_idents),*]) - .with_account_infos(&all_compressed_infos) - .invoke(cpi_accounts)?; - - Ok(true) - } -} - -/// Generate compression blocks for PDA fields -fn generate_pda_compress_blocks( - fields: &[RentFreeField], - _params_ident: &syn::Ident, -) -> (Vec, Vec) { - let mut blocks = Vec::new(); - let mut addr_idents = Vec::new(); - - for (idx, field) in fields.iter().enumerate() { - let idx_lit = idx as u8; - let ident = &field.ident; - let addr_tree_info = &field.address_tree_info; - let output_tree = &field.output_tree; - let acc_ty_path = extract_inner_account_type(&field.ty); - - let new_addr_params_ident = format_ident!("__new_addr_params_{}", idx); - let compressed_infos_ident = format_ident!("__compressed_infos_{}", idx); - let address_ident = format_ident!("__address_{}", idx); - let account_info_ident = format_ident!("__account_info_{}", idx); - let account_key_ident = format_ident!("__account_key_{}", idx); - let account_data_ident = format_ident!("__account_data_{}", idx); - - // Generate correct deref pattern: ** for Box>, * for Account - let deref_expr = if field.is_boxed { - quote! { &mut **self.#ident } - } else { - quote! { &mut *self.#ident } - }; - - addr_idents.push(quote! { #new_addr_params_ident }); - - blocks.push(quote! { - // Get account info early before any mutable borrows - let #account_info_ident = self.#ident.to_account_info(); - let #account_key_ident = #account_info_ident.key.to_bytes(); - - let #new_addr_params_ident = { - let tree_info = &#addr_tree_info; - light_compressed_account::instruction_data::data::NewAddressParamsAssignedPacked { - seed: #account_key_ident, - address_merkle_tree_account_index: tree_info.address_merkle_tree_pubkey_index, - address_queue_account_index: tree_info.address_queue_pubkey_index, - address_merkle_tree_root_index: tree_info.root_index, - assigned_to_account: true, - assigned_account_index: #idx_lit, - } - }; - - // Derive the compressed address - let #address_ident = light_compressed_account::address::derive_address( - &#new_addr_params_ident.seed, - &cpi_accounts - .get_tree_account_info(#new_addr_params_ident.address_merkle_tree_account_index as usize)? - .key() - .to_bytes(), - &crate::ID.to_bytes(), - ); - - // Get mutable reference to inner account data - let #account_data_ident = #deref_expr; - - let #compressed_infos_ident = light_sdk::compressible::prepare_compressed_account_on_init::<#acc_ty_path>( - &#account_info_ident, - #account_data_ident, - &compression_config_data, - #address_ident, - #new_addr_params_ident, - #output_tree, - &cpi_accounts, - &compression_config_data.address_space, - false, // at init, we do not compress_and_close the pda, we just "register" the empty compressed account with the derived address. - )?; - all_compressed_infos.push(#compressed_infos_ident); - }); - } - - (blocks, addr_idents) -} - -/// Extract the inner type T from Account<'info, T> or Box> -fn extract_inner_account_type(ty: &syn::Type) -> TokenStream { - match ty { - syn::Type::Path(type_path) => { - let path = &type_path.path; - if let Some(segment) = path.segments.last() { - let ident_str = segment.ident.to_string(); - - if ident_str == "Account" { - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - for arg in &args.args { - if let syn::GenericArgument::Type(inner_ty) = arg { - return quote! { #inner_ty }; - } - } - } - } - - if ident_str == "Box" { - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { - return extract_inner_account_type(inner); - } - } - } - } - quote! { #ty } - } - _ => quote! { #ty }, - } -} diff --git a/sdk-libs/macros/src/finalize/mod.rs b/sdk-libs/macros/src/finalize/mod.rs deleted file mode 100644 index 2f6b3991fc..0000000000 --- a/sdk-libs/macros/src/finalize/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! RentFree derive macro for Accounts structs. -//! -//! This module provides: -//! - `#[derive(RentFree)]` - Generates the LightFinalize trait impl for accounts structs -//! with fields marked `#[rentfree(...)]` -//! -//! Note: Instruction handlers are auto-wrapped by `#[rentfree_program]`. - -mod codegen; -mod parse; - -use proc_macro2::TokenStream; -use syn::DeriveInput; - -pub fn derive_light_finalize(input: DeriveInput) -> Result { - let parsed = parse::parse_compressible_struct(&input)?; - Ok(codegen::generate_finalize_impl(&parsed)) -} diff --git a/sdk-libs/macros/src/finalize/parse.rs b/sdk-libs/macros/src/finalize/parse.rs deleted file mode 100644 index 869073ba13..0000000000 --- a/sdk-libs/macros/src/finalize/parse.rs +++ /dev/null @@ -1,389 +0,0 @@ -//! Parsing logic for #[rentfree(...)] and #[light_mint(...)] attributes. - -use syn::{ - parse::{Parse, ParseStream}, - punctuated::Punctuated, - DeriveInput, Error, Expr, Ident, Token, Type, -}; - -/// Parsed representation of a struct with rentfree and light_mint fields. -pub struct ParsedCompressibleStruct { - pub struct_name: Ident, - pub generics: syn::Generics, - pub rentfree_fields: Vec, - pub light_mint_fields: Vec, - pub instruction_args: Option>, - pub fee_payer_field: Option, - pub compression_config_field: Option, - /// CToken compressible config account (for decompress mint) - pub ctoken_config_field: Option, - /// CToken rent sponsor account (for decompress mint) - pub ctoken_rent_sponsor_field: Option, - /// CToken program account (for decompress mint CPI) - pub ctoken_program_field: Option, - /// CToken CPI authority PDA (for decompress mint CPI) - pub ctoken_cpi_authority_field: Option, -} - -/// A field marked with #[rentfree(...)] -pub struct RentFreeField { - pub ident: Ident, - pub ty: Type, - pub address_tree_info: Expr, - pub output_tree: Expr, - /// True if the field is Box>, false if Account - pub is_boxed: bool, -} - -/// A field marked with #[light_mint(...)] -pub struct LightMintField { - /// The field name where #[light_mint] is attached (CMint account) - pub field_ident: Ident, - /// The mint_signer field (AccountInfo that seeds the mint PDA) - pub mint_signer: Expr, - /// The authority for mint operations - pub authority: Expr, - /// Decimals for the mint - pub decimals: Expr, - /// Address tree info expression - pub address_tree_info: Expr, - /// Optional freeze authority - pub freeze_authority: Option, - /// Signer seeds for the mint_signer PDA (required if mint_signer is a PDA) - pub signer_seeds: Option, - /// Rent payment epochs for decompression (default: 2) - pub rent_payment: Option, - /// Write top-up lamports for decompression (default: 0) - pub write_top_up: Option, -} - -/// Instruction argument from #[instruction(...)] -pub struct InstructionArg { - pub name: Ident, - pub ty: Type, -} - -/// Arguments inside #[rentfree(...)] -struct RentFreeArgs { - address_tree_info: Option, - output_tree: Option, -} - -impl Parse for RentFreeArgs { - fn parse(input: ParseStream) -> syn::Result { - let mut args = RentFreeArgs { - address_tree_info: None, - output_tree: None, - }; - - let content: Punctuated = Punctuated::parse_terminated(input)?; - - for arg in content { - match arg.name.to_string().as_str() { - "address_tree_info" => args.address_tree_info = Some(arg.value), - "output_tree" => args.output_tree = Some(arg.value), - other => { - return Err(Error::new( - arg.name.span(), - format!("unknown rentfree attribute: {}", other), - )) - } - } - } - - Ok(args) - } -} - -/// Arguments inside #[light_mint(...)] -struct LightMintArgs { - mint_signer: Option, - authority: Option, - decimals: Option, - address_tree_info: Option, - freeze_authority: Option, - signer_seeds: Option, - rent_payment: Option, - write_top_up: Option, -} - -impl Parse for LightMintArgs { - fn parse(input: ParseStream) -> syn::Result { - let mut args = LightMintArgs { - mint_signer: None, - authority: None, - decimals: None, - address_tree_info: None, - freeze_authority: None, - signer_seeds: None, - rent_payment: None, - write_top_up: None, - }; - - let content: Punctuated = Punctuated::parse_terminated(input)?; - - for arg in content { - match arg.name.to_string().as_str() { - "mint_signer" => args.mint_signer = Some(arg.value), - "authority" => args.authority = Some(arg.value), - "decimals" => args.decimals = Some(arg.value), - "address_tree_info" => args.address_tree_info = Some(arg.value), - "freeze_authority" => args.freeze_authority = Some(arg.value), - "signer_seeds" => args.signer_seeds = Some(arg.value), - "rent_payment" => args.rent_payment = Some(arg.value), - "write_top_up" => args.write_top_up = Some(arg.value), - other => { - return Err(Error::new( - arg.name.span(), - format!("unknown light_mint attribute: {}", other), - )) - } - } - } - - Ok(args) - } -} - -/// Generic key = value argument parser -struct KeyValueArg { - name: Ident, - value: Expr, -} - -impl Parse for KeyValueArg { - fn parse(input: ParseStream) -> syn::Result { - let name: Ident = input.parse()?; - input.parse::()?; - let value: Expr = input.parse()?; - Ok(KeyValueArg { name, value }) - } -} - -/// A single instruction argument: `name: Type` -struct InstructionArgParsed { - name: Ident, - _colon: Token![:], - ty: Type, -} - -impl Parse for InstructionArgParsed { - fn parse(input: ParseStream) -> syn::Result { - Ok(InstructionArgParsed { - name: input.parse()?, - _colon: input.parse()?, - ty: input.parse()?, - }) - } -} - -/// Parse #[instruction(...)] attribute from struct -fn parse_instruction_attr(attrs: &[syn::Attribute]) -> Option> { - for attr in attrs { - if attr.path().is_ident("instruction") { - if let Ok(args) = attr.parse_args_with(|input: ParseStream| { - let content: Punctuated = - Punctuated::parse_terminated(input)?; - Ok(content - .into_iter() - .map(|arg| InstructionArg { - name: arg.name, - ty: arg.ty, - }) - .collect::>()) - }) { - return Some(args); - } - } - } - None -} - -/// Check if a type is Account<...> or Box> -fn extract_account_type(ty: &Type) -> Option<(bool, &syn::Path)> { - match ty { - Type::Path(type_path) => { - let path = &type_path.path; - if let Some(segment) = path.segments.last() { - let ident_str = segment.ident.to_string(); - if ident_str == "Account" { - return Some((false, path)); - } - if ident_str == "Box" { - // Check for Box> - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - if let Some(syn::GenericArgument::Type(Type::Path(inner_path))) = - args.args.first() - { - if let Some(inner_seg) = inner_path.path.segments.last() { - if inner_seg.ident == "Account" { - return Some((true, &inner_path.path)); - } - } - } - } - } - } - None - } - _ => None, - } -} - -/// Parse a struct to extract rentfree and light_mint fields -pub fn parse_compressible_struct(input: &DeriveInput) -> Result { - let struct_name = input.ident.clone(); - let generics = input.generics.clone(); - - let instruction_args = parse_instruction_attr(&input.attrs); - - let fields = match &input.data { - syn::Data::Struct(data) => match &data.fields { - syn::Fields::Named(fields) => &fields.named, - _ => return Err(Error::new_spanned(input, "expected named fields")), - }, - _ => return Err(Error::new_spanned(input, "expected struct")), - }; - - let mut rentfree_fields = Vec::new(); - let mut light_mint_fields = Vec::new(); - let mut fee_payer_field = None; - let mut compression_config_field = None; - let mut ctoken_config_field = None; - let mut ctoken_rent_sponsor_field = None; - let mut ctoken_program_field = None; - let mut ctoken_cpi_authority_field = None; - - for field in fields { - let field_ident = field.ident.clone().unwrap(); - let field_name = field_ident.to_string(); - - // Track special fields by name - if field_name == "fee_payer" || field_name == "payer" || field_name == "creator" { - fee_payer_field = Some(field_ident.clone()); - } - if field_name == "compression_config" { - compression_config_field = Some(field_ident.clone()); - } - // Track ctoken-related fields for decompress mint - if field_name == "ctoken_compressible_config" - || field_name == "ctoken_config" - || field_name == "light_token_config_account" - { - ctoken_config_field = Some(field_ident.clone()); - } - if field_name == "ctoken_rent_sponsor" || field_name == "light_token_rent_sponsor" { - ctoken_rent_sponsor_field = Some(field_ident.clone()); - } - if field_name == "ctoken_program" || field_name == "light_token_program" { - ctoken_program_field = Some(field_ident.clone()); - } - if field_name == "ctoken_cpi_authority" - || field_name == "light_token_program_cpi_authority" - || field_name == "compress_token_program_cpi_authority" - { - ctoken_cpi_authority_field = Some(field_ident.clone()); - } - - // Look for #[rentfree] or #[rentfree(...)] attribute - for attr in &field.attrs { - if attr.path().is_ident("rentfree") { - // Handle both #[rentfree] and #[rentfree(...)] - let args: RentFreeArgs = match &attr.meta { - syn::Meta::Path(_) => { - // No parentheses: #[rentfree] - RentFreeArgs { - address_tree_info: None, - output_tree: None, - } - } - syn::Meta::List(_) => { - // Has parentheses: #[rentfree(...)] - attr.parse_args()? - } - syn::Meta::NameValue(_) => { - return Err(Error::new_spanned( - attr, - "expected #[rentfree] or #[rentfree(...)]", - )); - } - }; - - // Use defaults if not specified: - // - address_tree_info defaults to params.create_accounts_proof.address_tree_info - // - output_tree defaults to params.create_accounts_proof.output_state_tree_index - let address_tree_info = args.address_tree_info.unwrap_or_else(|| { - syn::parse_quote!(params.create_accounts_proof.address_tree_info) - }); - let output_tree = args.output_tree.unwrap_or_else(|| { - syn::parse_quote!(params.create_accounts_proof.output_state_tree_index) - }); - - // Validate this is an Account type and check if it's boxed - let (is_boxed, _) = extract_account_type(&field.ty).ok_or_else(|| { - Error::new_spanned( - &field.ty, - "#[rentfree] can only be applied to Account<...> fields", - ) - })?; - - rentfree_fields.push(RentFreeField { - ident: field_ident.clone(), - ty: field.ty.clone(), - address_tree_info, - output_tree, - is_boxed, - }); - break; - } - - // Look for #[light_mint(...)] attribute - if attr.path().is_ident("light_mint") { - let args: LightMintArgs = attr.parse_args()?; - - // Validate required fields - let mint_signer = args - .mint_signer - .ok_or_else(|| Error::new_spanned(attr, "light_mint requires mint_signer"))?; - let authority = args - .authority - .ok_or_else(|| Error::new_spanned(attr, "light_mint requires authority"))?; - let decimals = args - .decimals - .ok_or_else(|| Error::new_spanned(attr, "light_mint requires decimals"))?; - - // address_tree_info defaults to params.create_accounts_proof.address_tree_info - let address_tree_info = args.address_tree_info.unwrap_or_else(|| { - syn::parse_quote!(params.create_accounts_proof.address_tree_info) - }); - - light_mint_fields.push(LightMintField { - field_ident: field_ident.clone(), - mint_signer, - authority, - decimals, - address_tree_info, - freeze_authority: args.freeze_authority, - signer_seeds: args.signer_seeds, - rent_payment: args.rent_payment, - write_top_up: args.write_top_up, - }); - break; - } - } - } - - Ok(ParsedCompressibleStruct { - struct_name, - generics, - rentfree_fields, - light_mint_fields, - instruction_args, - fee_payer_field, - compression_config_field, - ctoken_config_field, - ctoken_rent_sponsor_field, - ctoken_program_field, - ctoken_cpi_authority_field, - }) -} diff --git a/sdk-libs/macros/src/lib.rs b/sdk-libs/macros/src/lib.rs index 64755504ef..369ae0dd42 100644 --- a/sdk-libs/macros/src/lib.rs +++ b/sdk-libs/macros/src/lib.rs @@ -6,11 +6,10 @@ use syn::{parse_macro_input, DeriveInput, ItemStruct}; use utils::into_token_stream; mod account; -mod compressible; mod discriminator; -mod finalize; mod hasher; mod rent_sponsor; +mod rentfree; mod utils; #[proc_macro_derive(LightDiscriminator)] @@ -125,7 +124,7 @@ pub fn light_hasher_sha(input: TokenStream) -> TokenStream { #[proc_macro_derive(HasCompressionInfo)] pub fn has_compression_info(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - into_token_stream(compressible::traits::derive_has_compression_info(input)) + into_token_stream(rentfree::traits::traits::derive_has_compression_info(input)) } /// Legacy CompressAs trait implementation (use Compressible instead). @@ -165,7 +164,7 @@ pub fn has_compression_info(input: TokenStream) -> TokenStream { #[proc_macro_derive(CompressAs, attributes(compress_as))] pub fn compress_as_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); - into_token_stream(compressible::traits::derive_compress_as(input)) + into_token_stream(rentfree::traits::traits::derive_compress_as(input)) } /// Auto-discovering rent-free program macro that reads external module files. @@ -203,7 +202,7 @@ pub fn compress_as_derive(input: TokenStream) -> TokenStream { #[proc_macro_attribute] pub fn rentfree_program(args: TokenStream, input: TokenStream) -> TokenStream { let module = syn::parse_macro_input!(input as syn::ItemMod); - into_token_stream(compressible::instructions::compressible_program_impl( + into_token_stream(rentfree::program::rentfree_program_impl( args.into(), module, )) @@ -258,7 +257,7 @@ pub fn account(_: TokenStream, input: TokenStream) -> TokenStream { #[proc_macro_derive(Compressible, attributes(compress_as, light_seeds))] pub fn compressible_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - into_token_stream(compressible::traits::derive_compressible(input)) + into_token_stream(rentfree::traits::traits::derive_compressible(input)) } /// Automatically implements Pack and Unpack traits for compressible accounts. @@ -285,7 +284,9 @@ pub fn compressible_derive(input: TokenStream) -> TokenStream { #[proc_macro_derive(CompressiblePack)] pub fn compressible_pack(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - into_token_stream(compressible::pack_unpack::derive_compressible_pack(input)) + into_token_stream(rentfree::traits::pack_unpack::derive_compressible_pack( + input, + )) } /// Consolidates all required traits for rent-free state accounts into a single derive. @@ -333,9 +334,7 @@ pub fn compressible_pack(input: TokenStream) -> TokenStream { #[proc_macro_derive(RentFreeAccount, attributes(compress_as))] pub fn rent_free_account(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - into_token_stream(compressible::light_compressible::derive_light_compressible( - input, - )) + into_token_stream(rentfree::traits::light_compressible::derive_rentfree_account(input)) } /// Derives a Rent Sponsor PDA for a program at compile time. @@ -456,5 +455,5 @@ pub fn derive_light_rent_sponsor(input: TokenStream) -> TokenStream { )] pub fn rent_free_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - into_token_stream(finalize::derive_light_finalize(input)) + into_token_stream(rentfree::accounts::derive_rentfree(input)) } diff --git a/sdk-libs/macros/src/rentfree/README.md b/sdk-libs/macros/src/rentfree/README.md new file mode 100644 index 0000000000..6c78ca533b --- /dev/null +++ b/sdk-libs/macros/src/rentfree/README.md @@ -0,0 +1,58 @@ +# Rent-Free Macros + +Procedural macros for generating rent-free account types and their hooks for Solana programs. + +## Directory Structure + +``` +rentfree/ +├── mod.rs # Module declaration +├── README.md # This file +├── accounts/ # #[derive(RentFree)] implementation +│ ├── mod.rs # Entry point: derive_rentfree() +│ ├── parse.rs # Parsing #[rentfree], #[light_mint] attributes +│ └── codegen.rs # LightPreInit/LightFinalize trait generation +├── program/ # #[rentfree_program] implementation +│ ├── mod.rs # Entry point: rentfree_program_impl() +│ ├── instructions.rs # Instruction generation and handler wrapping +│ ├── crate_context.rs # Crate scanning for #[derive(Accounts)] structs +│ ├── variant_enum.rs # RentFreeAccountVariant enum generation +│ └── seed_providers.rs # PDA/CToken seed derivation implementations +└── traits/ # Shared trait derive macros + ├── mod.rs # Module declaration + ├── traits.rs # HasCompressionInfo, CompressAs, Compressible + ├── pack_unpack.rs # Pack/Unpack trait implementations + ├── light_compressible.rs # RentFreeAccount combined derive + ├── anchor_seeds.rs # Seed extraction from Anchor attributes + ├── decompress_context.rs # DecompressContext trait generation + └── utils.rs # Shared utility functions +``` + +## Modules + +### `accounts/` - RentFree Derive Macro + +Implements `#[derive(RentFree)]` for Anchor Accounts structs: + +- **parse.rs** - Parses `#[rentfree]`, `#[rentfree_token]`, `#[light_mint]` attributes +- **codegen.rs** - Generates `LightPreInit` and `LightFinalize` trait implementations + +### `program/` - RentFree Program Macro + +Implements `#[rentfree_program]` attribute macro: + +- **instructions.rs** - Main macro logic, generates compress/decompress handlers +- **crate_context.rs** - Scans crate for `#[derive(Accounts)]` structs +- **variant_enum.rs** - Generates `RentFreeAccountVariant` enum with all traits +- **seed_providers.rs** - PDA and CToken seed provider implementations + +### `traits/` - Shared Trait Derives + +Core trait implementations shared across macros: + +- **traits.rs** - `HasCompressionInfo`, `CompressAs`, `Compressible` derives +- **pack_unpack.rs** - Generates `PackedXxx` structs, `Pack`/`Unpack` traits +- **light_compressible.rs** - `RentFreeAccount` combined derive macro +- **anchor_seeds.rs** - Extracts seeds from `#[account(seeds = [...])]` +- **decompress_context.rs** - `DecompressContext` trait generation +- **utils.rs** - Shared utilities (e.g., empty CToken enum generation) diff --git a/sdk-libs/macros/src/rentfree/accounts/light_mint.rs b/sdk-libs/macros/src/rentfree/accounts/light_mint.rs new file mode 100644 index 0000000000..1f45f12677 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/accounts/light_mint.rs @@ -0,0 +1,340 @@ +//! Light mint parsing and code generation. +//! +//! This module handles: +//! - Parsing of #[light_mint(...)] attributes using darling +//! - Code generation for mint_action CPI invocations + +use darling::FromMeta; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Expr, Ident}; + +// ============================================================================ +// Parsing with darling +// ============================================================================ + +/// Wrapper for syn::Expr that implements darling's FromMeta trait. +/// Enables darling to parse arbitrary expressions in attributes like +/// `#[light_mint(mint_signer = self.authority)]`. +#[derive(Clone)] +struct MetaExpr(Expr); + +impl FromMeta for MetaExpr { + fn from_expr(expr: &Expr) -> darling::Result { + Ok(MetaExpr(expr.clone())) + } +} + +impl From for Expr { + fn from(meta: MetaExpr) -> Expr { + meta.0 + } +} + +/// A field marked with #[light_mint(...)] +pub(super) struct LightMintField { + /// The field name where #[light_mint] is attached (CMint account) + pub field_ident: Ident, + /// The mint_signer field (AccountInfo that seeds the mint PDA) + pub mint_signer: Expr, + /// The authority for mint operations + pub authority: Expr, + /// Decimals for the mint + pub decimals: Expr, + /// Address tree info expression + pub address_tree_info: Expr, + /// Optional freeze authority + pub freeze_authority: Option, + /// Signer seeds for the mint_signer PDA (required if mint_signer is a PDA) + pub signer_seeds: Option, + /// Rent payment epochs for decompression (default: 2) + pub rent_payment: Option, + /// Write top-up lamports for decompression (default: 0) + pub write_top_up: Option, +} + +/// Arguments inside #[light_mint(...)] parsed by darling. +/// +/// Required fields (darling auto-validates): mint_signer, authority, decimals +/// Optional fields: address_tree_info, freeze_authority, signer_seeds, rent_payment, write_top_up +#[derive(FromMeta)] +struct LightMintArgs { + /// The mint_signer field (AccountInfo that seeds the mint PDA) - REQUIRED + mint_signer: MetaExpr, + /// The authority for mint operations - REQUIRED + authority: MetaExpr, + /// Decimals for the mint - REQUIRED + decimals: MetaExpr, + /// Address tree info expression (defaults to params.create_accounts_proof.address_tree_info) + #[darling(default)] + address_tree_info: Option, + /// Optional freeze authority + #[darling(default)] + freeze_authority: Option, + /// Signer seeds for the mint_signer PDA (required if mint_signer is a PDA) + #[darling(default)] + signer_seeds: Option, + /// Rent payment epochs for decompression + #[darling(default)] + rent_payment: Option, + /// Write top-up lamports for decompression + #[darling(default)] + write_top_up: Option, +} + +/// Parse #[light_mint(...)] attribute from a field. +/// Returns None if no light_mint attribute, Some(LightMintField) if found. +pub(super) fn parse_light_mint_attr( + field: &syn::Field, + field_ident: &Ident, +) -> Result, syn::Error> { + for attr in &field.attrs { + if attr.path().is_ident("light_mint") { + // Use darling to parse the attribute arguments + let args = LightMintArgs::from_meta(&attr.meta) + .map_err(|e| syn::Error::new_spanned(attr, e.to_string()))?; + + // address_tree_info defaults to params.create_accounts_proof.address_tree_info + let address_tree_info = args.address_tree_info.map(Into::into).unwrap_or_else(|| { + syn::parse_quote!(params.create_accounts_proof.address_tree_info) + }); + + return Ok(Some(LightMintField { + field_ident: field_ident.clone(), + mint_signer: args.mint_signer.into(), + authority: args.authority.into(), + decimals: args.decimals.into(), + address_tree_info, + freeze_authority: args.freeze_authority.map(Into::into), + signer_seeds: args.signer_seeds.map(Into::into), + rent_payment: args.rent_payment.map(Into::into), + write_top_up: args.write_top_up.map(Into::into), + })); + } + } + Ok(None) +} + +// ============================================================================ +// Code Generation +// ============================================================================ + +/// Default rent payment period in epochs (how long to prepay rent for decompressed accounts). +const DEFAULT_RENT_PAYMENT_EPOCHS: u8 = 2; + +/// Default write top-up in lamports (additional lamports for write operations during decompression). +const DEFAULT_WRITE_TOP_UP_LAMPORTS: u32 = 0; + +/// Generate token stream for signer seeds (explicit or empty default) +fn generate_signer_seeds_tokens(signer_seeds: &Option) -> TokenStream { + if let Some(seeds) = signer_seeds { + quote! { #seeds } + } else { + quote! { &[] as &[&[u8]] } + } +} + +/// Generate token stream for freeze authority expression +fn generate_freeze_authority_tokens(freeze_authority: &Option) -> TokenStream { + if let Some(freeze_auth) = freeze_authority { + quote! { Some(*self.#freeze_auth.to_account_info().key) } + } else { + quote! { None } + } +} + +/// Generate token stream for rent payment with default +fn generate_rent_payment_tokens(rent_payment: &Option) -> TokenStream { + if let Some(rent) = rent_payment { + quote! { #rent } + } else { + let default = DEFAULT_RENT_PAYMENT_EPOCHS; + quote! { #default } + } +} + +/// Generate token stream for write top-up with default +fn generate_write_top_up_tokens(write_top_up: &Option) -> TokenStream { + if let Some(top_up) = write_top_up { + quote! { #top_up } + } else { + let default = DEFAULT_WRITE_TOP_UP_LAMPORTS; + quote! { #default } + } +} + +/// Configuration for mint_action CPI generation +pub(super) struct MintActionConfig<'a> { + pub mint: &'a LightMintField, + pub params_ident: &'a syn::Ident, + pub fee_payer: &'a TokenStream, + pub ctoken_config: &'a TokenStream, + pub ctoken_rent_sponsor: &'a TokenStream, + pub light_token_program: &'a TokenStream, + pub ctoken_cpi_authority: &'a TokenStream, + /// CPI context config: (output_tree_index_expr, assigned_account_index) + /// None = no CPI context (mints-only case) + pub cpi_context: Option<(TokenStream, u8)>, +} + +/// Generate mint_action invocation with optional CPI context +pub(super) fn generate_mint_action_invocation(config: &MintActionConfig) -> TokenStream { + let MintActionConfig { + mint, + params_ident, + fee_payer, + ctoken_config, + ctoken_rent_sponsor, + light_token_program, + ctoken_cpi_authority, + cpi_context, + } = config; + + let mint_field_ident = &mint.field_ident; + let mint_signer = &mint.mint_signer; + let authority = &mint.authority; + let decimals = &mint.decimals; + let address_tree_info = &mint.address_tree_info; + + let signer_seeds_tokens = generate_signer_seeds_tokens(&mint.signer_seeds); + let freeze_authority_tokens = generate_freeze_authority_tokens(&mint.freeze_authority); + let rent_payment_tokens = generate_rent_payment_tokens(&mint.rent_payment); + let write_top_up_tokens = generate_write_top_up_tokens(&mint.write_top_up); + + // Queue access differs based on CPI context presence + let queue_access = if cpi_context.is_some() { + quote! { __output_tree_index as usize } + } else { + quote! { __tree_info.address_queue_pubkey_index as usize } + }; + + // CPI context setup block (empty if no CPI context) + let cpi_context_setup = if let Some((output_tree_expr, _)) = cpi_context { + quote! { + let __output_tree_index = #output_tree_expr; + } + } else { + quote! {} + }; + + // CPI context chain method (empty if no CPI context) + let cpi_context_chain = if let Some((output_tree_expr, assigned_idx)) = cpi_context { + quote! { + .with_cpi_context(light_token_interface::instructions::mint_action::CpiContext { + address_tree_pubkey: __tree_pubkey.to_bytes(), + set_context: false, + first_set_context: false, + in_tree_index: #output_tree_expr + 1, + in_queue_index: #output_tree_expr, + out_queue_index: #output_tree_expr, + token_out_queue_index: 0, + assigned_account_index: #assigned_idx, + read_only_address_trees: [0; 4], + }) + } + } else { + quote! {} + }; + + // CPI context on meta_config (only if CPI context present) + let meta_cpi_context = if cpi_context.is_some() { + quote! { meta_config.cpi_context = Some(*cpi_accounts.cpi_context()?.key); } + } else { + quote! {} + }; + + // Use `let mut` only when CPI context needs modification + let instruction_data_binding = if cpi_context.is_some() { + quote! { let mut instruction_data } + } else { + quote! { let instruction_data } + }; + + quote! { + { + let __tree_info = &#address_tree_info; + let address_tree = cpi_accounts.get_tree_account_info(__tree_info.address_merkle_tree_pubkey_index as usize)?; + #cpi_context_setup + let output_queue = cpi_accounts.get_tree_account_info(#queue_access)?; + let __tree_pubkey: solana_pubkey::Pubkey = light_sdk::light_account_checks::AccountInfoTrait::pubkey(address_tree); + + let mint_signer_key = self.#mint_signer.to_account_info().key; + let (mint_pda, _cmint_bump) = light_token_sdk::token::find_mint_address(mint_signer_key); + + let __proof: light_token_sdk::CompressedProof = #params_ident.create_accounts_proof.proof.0.clone() + .expect("proof is required for mint creation"); + + let __freeze_authority: Option = #freeze_authority_tokens; + + let compressed_mint_data = light_token_interface::instructions::mint_action::MintInstructionData { + supply: 0, + decimals: #decimals, + metadata: light_token_interface::state::MintMetadata { + version: 3, + mint: mint_pda.to_bytes().into(), + mint_decompressed: false, + mint_signer: mint_signer_key.to_bytes(), + bump: _cmint_bump, + }, + mint_authority: Some((*self.#authority.to_account_info().key).to_bytes().into()), + freeze_authority: __freeze_authority.map(|a| a.to_bytes().into()), + extensions: None, + }; + + #instruction_data_binding = light_token_interface::instructions::mint_action::MintActionCompressedInstructionData::new_mint( + __tree_info.root_index, + __proof, + compressed_mint_data, + ) + .with_decompress_mint(light_token_interface::instructions::mint_action::DecompressMintAction { + rent_payment: #rent_payment_tokens, + write_top_up: #write_top_up_tokens, + }) + #cpi_context_chain; + + let mut meta_config = light_token_sdk::compressed_token::mint_action::MintActionMetaConfig::new_create_mint( + *self.#fee_payer.to_account_info().key, + *self.#authority.to_account_info().key, + *mint_signer_key, + __tree_pubkey, + *output_queue.key, + ) + .with_compressible_mint( + mint_pda, + *self.#ctoken_config.to_account_info().key, + *self.#ctoken_rent_sponsor.to_account_info().key, + ); + + #meta_cpi_context + + let account_metas = meta_config.to_account_metas(); + + use light_compressed_account::instruction_data::traits::LightInstructionData; + let ix_data = instruction_data.data() + .map_err(|_| light_sdk::error::LightSdkError::Borsh)?; + + let mint_action_ix = anchor_lang::solana_program::instruction::Instruction { + program_id: solana_pubkey::Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID), + accounts: account_metas, + data: ix_data, + }; + + let mut account_infos = cpi_accounts.to_account_infos(); + account_infos.push(self.#light_token_program.to_account_info()); + account_infos.push(self.#ctoken_cpi_authority.to_account_info()); + account_infos.push(self.#mint_field_ident.to_account_info()); + account_infos.push(self.#ctoken_config.to_account_info()); + account_infos.push(self.#ctoken_rent_sponsor.to_account_info()); + account_infos.push(self.#authority.to_account_info()); + account_infos.push(self.#mint_signer.to_account_info()); + account_infos.push(self.#fee_payer.to_account_info()); + + let signer_seeds: &[&[u8]] = #signer_seeds_tokens; + if signer_seeds.is_empty() { + anchor_lang::solana_program::program::invoke(&mint_action_ix, &account_infos)?; + } else { + anchor_lang::solana_program::program::invoke_signed(&mint_action_ix, &account_infos, &[signer_seeds])?; + } + } + } +} diff --git a/sdk-libs/macros/src/rentfree/accounts/mod.rs b/sdk-libs/macros/src/rentfree/accounts/mod.rs new file mode 100644 index 0000000000..c10cc6a7c4 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/accounts/mod.rs @@ -0,0 +1,20 @@ +//! Rent-free accounts derive macro implementation. +//! +//! This module provides `#[derive(RentFree)]` which generates: +//! - `LightPreInit` trait implementation for pre-instruction compression setup +//! - `LightFinalize` trait implementation for post-instruction cleanup +//! - Supports rent-free PDAs, rent-free token accounts, and light mints + +mod light_mint; +mod parse; +mod pda; + +use proc_macro2::TokenStream; +use syn::DeriveInput; + +pub fn derive_rentfree(input: DeriveInput) -> Result { + let parsed = parse::parse_rentfree_struct(&input)?; + pda::generate_rentfree_impl(&parsed) +} + +// TODO: add a codegen file that puts the generated code together diff --git a/sdk-libs/macros/src/rentfree/accounts/parse.rs b/sdk-libs/macros/src/rentfree/accounts/parse.rs new file mode 100644 index 0000000000..cc3dfebe4a --- /dev/null +++ b/sdk-libs/macros/src/rentfree/accounts/parse.rs @@ -0,0 +1,272 @@ +//! Parsing logic for #[rentfree(...)] attributes using darling. + +use darling::FromMeta; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + DeriveInput, Error, Expr, Ident, Token, Type, +}; + +// Import LightMintField and parsing from light_mint module +use super::light_mint::{parse_light_mint_attr, LightMintField}; +// Import shared types from seed_extraction module +pub(super) use crate::rentfree::traits::seed_extraction::extract_account_inner_type; + +// ============================================================================ +// darling support for parsing Expr from attributes +// ============================================================================ + +/// Wrapper for syn::Expr that implements darling's FromMeta trait. +/// Enables darling to parse arbitrary expressions in attributes. +#[derive(Clone)] +struct MetaExpr(Expr); + +impl FromMeta for MetaExpr { + fn from_expr(expr: &Expr) -> darling::Result { + Ok(MetaExpr(expr.clone())) + } +} + +impl From for Expr { + fn from(meta: MetaExpr) -> Expr { + meta.0 + } +} + +/// Parsed representation of a struct with rentfree and light_mint fields. +pub(super) struct ParsedRentFreeStruct { + pub struct_name: Ident, + pub generics: syn::Generics, + pub rentfree_fields: Vec, + pub light_mint_fields: Vec, + pub instruction_args: Option>, + pub fee_payer_field: Option, + pub compression_config_field: Option, + /// CToken compressible config account (for decompress mint) + pub ctoken_config_field: Option, + /// CToken rent sponsor account (for decompress mint) + pub ctoken_rent_sponsor_field: Option, + /// CToken program account (for decompress mint CPI) + pub ctoken_program_field: Option, + /// CToken CPI authority PDA (for decompress mint CPI) + pub ctoken_cpi_authority_field: Option, +} + +/// A field marked with #[rentfree(...)] +pub(super) struct RentFreeField { + pub ident: Ident, + /// The inner type T from Account<'info, T> or Box> + pub inner_type: Ident, + pub address_tree_info: Expr, + pub output_tree: Expr, + /// True if the field is Box>, false if Account + pub is_boxed: bool, +} + +/// Instruction argument from #[instruction(...)] +pub(super) struct InstructionArg { + pub name: Ident, + pub ty: Type, +} + +impl Parse for InstructionArg { + fn parse(input: ParseStream) -> syn::Result { + let name: Ident = input.parse()?; + input.parse::()?; + let ty: Type = input.parse()?; + Ok(Self { name, ty }) + } +} + +fn rentfree_args_default() -> darling::Result { + Ok(RentFreeArgs::default()) +} + +/// Arguments inside #[rentfree(...)] parsed by darling. +/// +/// Supports both `#[rentfree]` (word format) and `#[rentfree(...)]` (list format). +/// All fields default to None if not specified. +#[derive(FromMeta, Default)] +#[darling(default, from_word = rentfree_args_default)] +struct RentFreeArgs { + /// Address tree info expression + address_tree_info: Option, + /// Output tree index expression + output_tree: Option, +} + +/// Parse #[instruction(...)] attribute from struct. +/// +/// Returns `Ok(None)` if no instruction attribute is present, +/// `Ok(Some(args))` if successfully parsed, or `Err` on malformed syntax. +fn parse_instruction_attr(attrs: &[syn::Attribute]) -> Result>, Error> { + for attr in attrs { + if attr.path().is_ident("instruction") { + let args = attr.parse_args_with(|input: ParseStream| { + let content: Punctuated = + Punctuated::parse_terminated(input)?; + Ok(content.into_iter().collect::>()) + })?; + return Ok(Some(args)); + } + } + Ok(None) +} + +/// Parse a struct to extract rentfree and light_mint fields +pub(super) fn parse_rentfree_struct(input: &DeriveInput) -> Result { + let struct_name = input.ident.clone(); + let generics = input.generics.clone(); + + let instruction_args = parse_instruction_attr(&input.attrs)?; + + let fields = match &input.data { + syn::Data::Struct(data) => match &data.fields { + syn::Fields::Named(fields) => &fields.named, + _ => return Err(Error::new_spanned(input, "expected named fields")), + }, + _ => return Err(Error::new_spanned(input, "expected struct")), + }; + + let mut rentfree_fields = Vec::new(); + let mut light_mint_fields = Vec::new(); + let mut fee_payer_field = None; + let mut compression_config_field = None; + let mut ctoken_config_field = None; + let mut ctoken_rent_sponsor_field = None; + let mut ctoken_program_field = None; + let mut ctoken_cpi_authority_field = None; + + for field in fields { + let field_ident = field + .ident + .clone() + .ok_or_else(|| Error::new_spanned(field, "expected named field with identifier"))?; + let field_name = field_ident.to_string(); + + // Track special fields by naming convention. + // + // The RentFree derive expects these conventional field names: + // + // Fee payer (who pays transaction fees and rent): + // - "fee_payer" (preferred), "payer", "creator" + // + // Compression config (holds compression settings for the program): + // - "compression_config" + // + // CToken fields (for compressed token mint operations): + // - Config: "ctoken_compressible_config", "ctoken_config", "light_token_config_account" + // - Rent sponsor: "ctoken_rent_sponsor", "light_token_rent_sponsor" + // - Program: "ctoken_program", "light_token_program" + // - CPI authority: "ctoken_cpi_authority", "light_token_program_cpi_authority", + // "compress_token_program_cpi_authority" + // + // Fields not matching these names will use defaults in code generation. + match field_name.as_str() { + "fee_payer" | "payer" | "creator" => { + fee_payer_field = Some(field_ident.clone()); + } + "compression_config" => { + compression_config_field = Some(field_ident.clone()); + } + "ctoken_compressible_config" | "ctoken_config" | "light_token_config_account" => { + ctoken_config_field = Some(field_ident.clone()); + } + "ctoken_rent_sponsor" | "light_token_rent_sponsor" => { + ctoken_rent_sponsor_field = Some(field_ident.clone()); + } + "ctoken_program" | "light_token_program" => { + ctoken_program_field = Some(field_ident.clone()); + } + "ctoken_cpi_authority" + | "light_token_program_cpi_authority" + | "compress_token_program_cpi_authority" => { + ctoken_cpi_authority_field = Some(field_ident.clone()); + } + _ => {} + } + + // Track if this field already has a compression attribute + let mut has_compression_attr = false; + + // Check for #[light_mint(...)] attribute first (delegated to light_mint module) + if let Some(mint_field) = parse_light_mint_attr(field, &field_ident)? { + has_compression_attr = true; + light_mint_fields.push(mint_field); + } + + // Look for #[rentfree] or #[rentfree(...)] attribute + for attr in &field.attrs { + if attr.path().is_ident("rentfree") { + // Check for duplicate compression attributes on same field + if has_compression_attr { + return Err(Error::new_spanned( + attr, + "Field already has a compression attribute (#[rentfree] or #[light_mint]). \ + Only one is allowed per field.", + )); + } + + // Use darling to parse the attribute arguments + // Handles both #[rentfree] and #[rentfree(...)] + let args = RentFreeArgs::from_meta(&attr.meta) + .map_err(|e| Error::new_spanned(attr, e.to_string()))?; + + // Use defaults if not specified + let address_tree_info = + args.address_tree_info.map(Into::into).unwrap_or_else(|| { + syn::parse_quote!(params.create_accounts_proof.address_tree_info) + }); + let output_tree = args.output_tree.map(Into::into).unwrap_or_else(|| { + syn::parse_quote!(params.create_accounts_proof.output_state_tree_index) + }); + + // Validate this is an Account type (or Box) + let (is_boxed, inner_type) = + extract_account_inner_type(&field.ty).ok_or_else(|| { + Error::new_spanned( + &field.ty, + "#[rentfree] can only be applied to Account<...> or Box> fields. \ + Nested Box> is not supported.", + ) + })?; + + rentfree_fields.push(RentFreeField { + ident: field_ident.clone(), + inner_type, + address_tree_info, + output_tree, + is_boxed, + }); + break; + } + + // TODO(diff-pr): Add parsing for #[rentfree_token(...)] attribute for token accounts and ATAs. + // Would need RentFreeTokenField struct with: field_ident, authority_seeds, mint field ref. + } + } + + // Validation: #[rentfree] and #[light_mint] require #[instruction] attribute + if (!rentfree_fields.is_empty() || !light_mint_fields.is_empty()) && instruction_args.is_none() + { + return Err(Error::new_spanned( + input, + "#[rentfree] and #[light_mint] fields require #[instruction(params: YourParamsType)] \ + attribute on the struct", + )); + } + + Ok(ParsedRentFreeStruct { + struct_name, + generics, + rentfree_fields, + light_mint_fields, + instruction_args, + fee_payer_field, + compression_config_field, + ctoken_config_field, + ctoken_rent_sponsor_field, + ctoken_program_field, + ctoken_cpi_authority_field, + }) +} diff --git a/sdk-libs/macros/src/rentfree/accounts/pda.rs b/sdk-libs/macros/src/rentfree/accounts/pda.rs new file mode 100644 index 0000000000..aed75e89df --- /dev/null +++ b/sdk-libs/macros/src/rentfree/accounts/pda.rs @@ -0,0 +1,423 @@ +//! Code generation for LightFinalize and LightPreInit trait implementations. +//! +//! Design for mints: +//! - At mint init, we CREATE + DECOMPRESS atomically +//! - After init, the CMint should always be in decompressed/"hot" state +//! +//! Flow for PDAs + mints: +//! 1. Pre-init: ALL compression logic executes here +//! a. Write PDAs to CPI context +//! b. Invoke mint_action with decompress + CPI context +//! c. CMint is now "hot" and usable +//! 2. Instruction body: Can use hot CMint (mintTo, transfers, etc.) +//! 3. Finalize: No-op (all work done in pre_init) + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::{ + light_mint::{generate_mint_action_invocation, MintActionConfig}, + parse::{ParsedRentFreeStruct, RentFreeField}, +}; + +/// Resolve optional field name to TokenStream, using default if None +fn resolve_field_name(field: &Option, default: &str) -> TokenStream { + field.as_ref().map(|f| quote! { #f }).unwrap_or_else(|| { + let ident = format_ident!("{}", default); + quote! { #ident } + }) +} + +/// Generate both trait implementations. +/// +/// Returns `Err` if the parsed struct has inconsistent state (e.g., params type without ident). +pub(super) fn generate_rentfree_impl( + parsed: &ParsedRentFreeStruct, +) -> Result { + let struct_name = &parsed.struct_name; + let (impl_generics, ty_generics, where_clause) = parsed.generics.split_for_impl(); + + // Validation: Ensure combined PDA + mint count fits in u8 (Light Protocol uses u8 for account indices) + let total_accounts = parsed.rentfree_fields.len() + parsed.light_mint_fields.len(); + if total_accounts > 255 { + return Err(syn::Error::new_spanned( + struct_name, + format!( + "Too many compression fields ({} PDAs + {} mints = {} total, maximum 255). \ + Light Protocol uses u8 for account indices.", + parsed.rentfree_fields.len(), + parsed.light_mint_fields.len(), + total_accounts + ), + )); + } + + // Extract first instruction arg or generate no-op impls + let first_arg = match parsed + .instruction_args + .as_ref() + .and_then(|args| args.first()) + { + Some(arg) => arg, + None => { + // No instruction args - generate no-op impls. + // Keep these for backwards compatibility with structs that derive RentFree + // without compression fields or instruction params. + return Ok(quote! { + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightPreInit<'info, ()> for #struct_name #ty_generics #where_clause { + fn light_pre_init( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + _params: &(), + ) -> std::result::Result { + Ok(false) + } + } + + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightFinalize<'info, ()> for #struct_name #ty_generics #where_clause { + fn light_finalize( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + _params: &(), + _has_pre_init: bool, + ) -> std::result::Result<(), light_sdk::error::LightSdkError> { + Ok(()) + } + } + }); + } + }; + + let params_type = &first_arg.ty; + let params_ident = &first_arg.name; + + let has_pdas = !parsed.rentfree_fields.is_empty(); + let has_mints = !parsed.light_mint_fields.is_empty(); + + // Resolve field names with defaults + let fee_payer = resolve_field_name(&parsed.fee_payer_field, "fee_payer"); + let compression_config = + resolve_field_name(&parsed.compression_config_field, "compression_config"); + let ctoken_config = + resolve_field_name(&parsed.ctoken_config_field, "ctoken_compressible_config"); + let ctoken_rent_sponsor = + resolve_field_name(&parsed.ctoken_rent_sponsor_field, "ctoken_rent_sponsor"); + let light_token_program = + resolve_field_name(&parsed.ctoken_program_field, "light_token_program"); + let ctoken_cpi_authority = + resolve_field_name(&parsed.ctoken_cpi_authority_field, "ctoken_cpi_authority"); + + // Generate LightPreInit impl based on what we have + // ALL compression logic runs in pre_init so instruction body can use hot state + let pre_init_body = if has_pdas && has_mints { + // PDAs + mints: Write PDAs to CPI context, then invoke mint_action with decompress + generate_pre_init_pdas_and_mints( + parsed, + params_ident, + &fee_payer, + &compression_config, + &ctoken_config, + &ctoken_rent_sponsor, + &light_token_program, + &ctoken_cpi_authority, + ) + } else if has_mints { + // Mints only: Invoke mint_action with decompress (no CPI context) + generate_pre_init_mints_only( + parsed, + params_ident, + &fee_payer, + &ctoken_config, + &ctoken_rent_sponsor, + &light_token_program, + &ctoken_cpi_authority, + ) + } else if has_pdas { + // PDAs only: Direct invoke (no CPI context needed) + generate_pre_init_pdas_only(parsed, params_ident, &fee_payer, &compression_config) + } else { + quote! { Ok(false) } + }; + + // LightFinalize: No-op (all work done in pre_init) + let finalize_body = quote! { Ok(()) }; + + Ok(quote! { + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightPreInit<'info, #params_type> for #struct_name #ty_generics #where_clause { + fn light_pre_init( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + #params_ident: &#params_type, + ) -> std::result::Result { + use anchor_lang::ToAccountInfo; + #pre_init_body + } + } + + #[automatically_derived] + impl #impl_generics light_sdk::compressible::LightFinalize<'info, #params_type> for #struct_name #ty_generics #where_clause { + fn light_finalize( + &mut self, + _remaining: &[solana_account_info::AccountInfo<'info>], + #params_ident: &#params_type, + _has_pre_init: bool, + ) -> std::result::Result<(), light_sdk::error::LightSdkError> { + use anchor_lang::ToAccountInfo; + #finalize_body + } + } + }) +} + +/// Generate LightPreInit body for PDAs + mints: +/// 1. Write PDAs to CPI context +/// 2. Invoke mint_action with decompress + CPI context +/// After this, Mint is "hot" and usable in instruction body +#[allow(clippy::too_many_arguments)] +fn generate_pre_init_pdas_and_mints( + parsed: &ParsedRentFreeStruct, + params_ident: &syn::Ident, + fee_payer: &TokenStream, + compression_config: &TokenStream, + ctoken_config: &TokenStream, + ctoken_rent_sponsor: &TokenStream, + light_token_program: &TokenStream, + ctoken_cpi_authority: &TokenStream, +) -> TokenStream { + let (compress_blocks, new_addr_idents) = generate_pda_compress_blocks(&parsed.rentfree_fields); + let rentfree_count = parsed.rentfree_fields.len() as u8; + let pda_count = parsed.rentfree_fields.len(); + + // Get the first PDA's output tree index (for the state tree output queue) + let first_pda_output_tree = &parsed.rentfree_fields[0].output_tree; + + // TODO(diff-pr): Support multiple #[light_mint] fields by looping here. + // Each mint would get assigned_account_index = pda_count + mint_index. + // Also add support for #[rentfree_token] fields for token ATAs. + let mint = &parsed.light_mint_fields[0]; + + // assigned_account_index for mint is after PDAs + let mint_assigned_index = pda_count as u8; + + // Generate mint action invocation with CPI context + let mint_invocation = generate_mint_action_invocation(&MintActionConfig { + mint, + params_ident, + fee_payer, + ctoken_config, + ctoken_rent_sponsor, + light_token_program, + ctoken_cpi_authority, + cpi_context: Some((quote! { #first_pda_output_tree }, mint_assigned_index)), + }); + + quote! { + // Build CPI accounts WITH CPI context for batching + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new_with_config( + &self.#fee_payer, + _remaining, + light_sdk_types::cpi_accounts::CpiAccountsConfig::new_with_cpi_context(crate::LIGHT_CPI_SIGNER), + ); + + // Load compression config + let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( + &self.#compression_config, + &crate::ID + )?; + + // Collect compressed infos for all rentfree PDA accounts + let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); + #(#compress_blocks)* + + // Step 1: Write PDAs to CPI context + let cpi_context_account = cpi_accounts.cpi_context()?; + let cpi_context_accounts = light_sdk_types::cpi_context_write::CpiContextWriteAccounts { + fee_payer: cpi_accounts.fee_payer(), + authority: cpi_accounts.authority()?, + cpi_context: cpi_context_account, + cpi_signer: crate::LIGHT_CPI_SIGNER, + }; + + use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; + light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( + crate::LIGHT_CPI_SIGNER, + #params_ident.create_accounts_proof.proof.clone() + ) + .with_new_addresses(&[#(#new_addr_idents),*]) + .with_account_infos(&all_compressed_infos) + .write_to_cpi_context_first() + .invoke_write_to_cpi_context_first(cpi_context_accounts)?; + + // Step 2: Build and invoke mint_action with decompress + CPI context + #mint_invocation + + Ok(true) + } +} + +/// Generate LightPreInit body for mints-only (no PDAs): +/// Invoke mint_action with decompress directly +/// After this, CMint is "hot" and usable in instruction body +#[allow(clippy::too_many_arguments)] +fn generate_pre_init_mints_only( + parsed: &ParsedRentFreeStruct, + params_ident: &syn::Ident, + fee_payer: &TokenStream, + ctoken_config: &TokenStream, + ctoken_rent_sponsor: &TokenStream, + light_token_program: &TokenStream, + ctoken_cpi_authority: &TokenStream, +) -> TokenStream { + // TODO(diff-pr): Support multiple #[light_mint] fields by looping here. + // Each mint would get assigned_account_index = mint_index. + // Also add support for #[rentfree_token] fields for token ATAs. + let mint = &parsed.light_mint_fields[0]; + + // Generate mint action invocation without CPI context + let mint_invocation = generate_mint_action_invocation(&MintActionConfig { + mint, + params_ident, + fee_payer, + ctoken_config, + ctoken_rent_sponsor, + light_token_program, + ctoken_cpi_authority, + cpi_context: None, + }); + + quote! { + // Build CPI accounts (no CPI context needed for mints-only) + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( + &self.#fee_payer, + _remaining, + crate::LIGHT_CPI_SIGNER, + ); + + // Build and invoke mint_action with decompress + #mint_invocation + + Ok(true) + } +} + +/// Generate LightPreInit body for PDAs only (no mints) +/// After this, compressed addresses are registered +fn generate_pre_init_pdas_only( + parsed: &ParsedRentFreeStruct, + params_ident: &syn::Ident, + fee_payer: &TokenStream, + compression_config: &TokenStream, +) -> TokenStream { + let (compress_blocks, new_addr_idents) = generate_pda_compress_blocks(&parsed.rentfree_fields); + let rentfree_count = parsed.rentfree_fields.len() as u8; + + quote! { + // Build CPI accounts (no CPI context needed for PDAs-only) + let cpi_accounts = light_sdk::cpi::v2::CpiAccounts::new( + &self.#fee_payer, + _remaining, + crate::LIGHT_CPI_SIGNER, + ); + + // Load compression config + let compression_config_data = light_sdk::compressible::CompressibleConfig::load_checked( + &self.#compression_config, + &crate::ID + )?; + + // Collect compressed infos for all rentfree accounts + let mut all_compressed_infos = Vec::with_capacity(#rentfree_count as usize); + #(#compress_blocks)* + + // Execute Light System Program CPI directly with proof + use light_sdk::cpi::{InvokeLightSystemProgram, LightCpiInstruction}; + light_sdk::cpi::v2::LightSystemProgramCpi::new_cpi( + crate::LIGHT_CPI_SIGNER, + #params_ident.create_accounts_proof.proof.clone() + ) + .with_new_addresses(&[#(#new_addr_idents),*]) + .with_account_infos(&all_compressed_infos) + .invoke(cpi_accounts)?; + + Ok(true) + } +} + +/// Generate compression blocks for PDA fields +fn generate_pda_compress_blocks(fields: &[RentFreeField]) -> (Vec, Vec) { + let mut blocks = Vec::new(); + let mut addr_idents = Vec::new(); + + for (idx, field) in fields.iter().enumerate() { + let idx_lit = idx as u8; + let ident = &field.ident; + let addr_tree_info = &field.address_tree_info; + let output_tree = &field.output_tree; + let inner_type = &field.inner_type; + + let new_addr_params_ident = format_ident!("__new_addr_params_{}", idx); + let compressed_infos_ident = format_ident!("__compressed_infos_{}", idx); + let address_ident = format_ident!("__address_{}", idx); + let account_info_ident = format_ident!("__account_info_{}", idx); + let account_key_ident = format_ident!("__account_key_{}", idx); + let account_data_ident = format_ident!("__account_data_{}", idx); + + // Generate correct deref pattern: ** for Box>, * for Account + let deref_expr = if field.is_boxed { + quote! { &mut **self.#ident } + } else { + quote! { &mut *self.#ident } + }; + + addr_idents.push(quote! { #new_addr_params_ident }); + + blocks.push(quote! { + // Get account info early before any mutable borrows + let #account_info_ident = self.#ident.to_account_info(); + let #account_key_ident = #account_info_ident.key.to_bytes(); + + let #new_addr_params_ident = { + let tree_info = &#addr_tree_info; + light_compressed_account::instruction_data::data::NewAddressParamsAssignedPacked { + seed: #account_key_ident, + address_merkle_tree_account_index: tree_info.address_merkle_tree_pubkey_index, + address_queue_account_index: tree_info.address_queue_pubkey_index, + address_merkle_tree_root_index: tree_info.root_index, + assigned_to_account: true, + assigned_account_index: #idx_lit, + } + }; + + // Derive the compressed address + let #address_ident = light_compressed_account::address::derive_address( + &#new_addr_params_ident.seed, + &cpi_accounts + .get_tree_account_info(#new_addr_params_ident.address_merkle_tree_account_index as usize)? + .key() + .to_bytes(), + &crate::ID.to_bytes(), + ); + + // Get mutable reference to inner account data + let #account_data_ident = #deref_expr; + + let #compressed_infos_ident = light_sdk::compressible::prepare_compressed_account_on_init::<#inner_type>( + &#account_info_ident, + #account_data_ident, + &compression_config_data, + #address_ident, + #new_addr_params_ident, + #output_tree, + &cpi_accounts, + &compression_config_data.address_space, + false, // at init, we do not compress_and_close the pda, we just "register" the empty compressed account with the derived address. + )?; + all_compressed_infos.push(#compressed_infos_ident); + }); + } + + (blocks, addr_idents) +} diff --git a/sdk-libs/macros/src/rentfree/mod.rs b/sdk-libs/macros/src/rentfree/mod.rs new file mode 100644 index 0000000000..e10bea0ab6 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/mod.rs @@ -0,0 +1,12 @@ +//! Rent-free account compression macros. +//! +//! This module organizes all rent-free related macros: +//! - `program/` - `#[rentfree_program]` attribute macro for program-level auto-discovery +//! - `accounts/` - `#[derive(RentFree)]` derive macro for Accounts structs +//! - `traits/` - Shared trait derive macros (Compressible, Pack, HasCompressionInfo, etc.) +//! - `shared_utils` - Common utilities (constant detection, identifier extraction) + +pub mod accounts; +pub mod program; +pub mod shared_utils; +pub mod traits; diff --git a/sdk-libs/macros/src/rentfree/program/compress.rs b/sdk-libs/macros/src/rentfree/program/compress.rs new file mode 100644 index 0000000000..02c180c5b4 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/compress.rs @@ -0,0 +1,224 @@ +//! Compress code generation. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Ident, Result}; + +use super::parsing::InstructionVariant; + +// ============================================================================= +// COMPRESS CONTEXT IMPL +// ============================================================================= + +pub fn generate_compress_context_impl(account_types: Vec) -> Result { + let lifetime: syn::Lifetime = syn::parse_quote!('info); + + let compress_arms: Vec<_> = account_types.iter().map(|name| { + 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::compressible::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(); + + Ok(syn::parse_quote! { + mod __compress_context_impl { + use super::*; + use light_sdk::LightDiscriminator; + use light_sdk::compressible::HasCompressionInfo; + + #[inline(always)] + fn __anchor_to_program_error>(e: E) -> solana_program_error::ProgramError { + let err: anchor_lang::error::Error = e.into(); + let program_error: anchor_lang::prelude::ProgramError = err.into(); + let code = match program_error { + anchor_lang::prelude::ProgramError::Custom(code) => code, + _ => 0, + }; + solana_program_error::ProgramError::Custom(code) + } + + impl<#lifetime> light_sdk::compressible::CompressContext<#lifetime> for CompressAccountsIdempotent<#lifetime> { + fn fee_payer(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &*self.fee_payer + } + + fn config(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.config + } + + fn rent_sponsor(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.rent_sponsor + } + + fn compression_authority(&self) -> &solana_account_info::AccountInfo<#lifetime> { + &self.compression_authority + } + + fn compress_pda_account( + &self, + account_info: &solana_account_info::AccountInfo<#lifetime>, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + cpi_accounts: &light_sdk::cpi::v2::CpiAccounts<'_, #lifetime>, + compression_config: &light_sdk::compressible::CompressibleConfig, + program_id: &solana_pubkey::Pubkey, + ) -> std::result::Result, solana_program_error::ProgramError> { + let data = account_info.try_borrow_data().map_err(__anchor_to_program_error)?; + let discriminator = &data[0..8]; + + match discriminator { + #(#compress_arms)* + _ => Err(__anchor_to_program_error(anchor_lang::error::ErrorCode::AccountDiscriminatorMismatch)) + } + } + } + } + }) +} + +// ============================================================================= +// COMPRESS PROCESSOR +// ============================================================================= + +pub fn generate_process_compress_accounts_idempotent() -> Result { + Ok(syn::parse_quote! { + #[inline(never)] + pub fn process_compress_accounts_idempotent<'info>( + accounts: &CompressAccountsIdempotent<'info>, + remaining_accounts: &[solana_account_info::AccountInfo<'info>], + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + light_sdk::compressible::compress_runtime::process_compress_pda_accounts_idempotent( + accounts, + remaining_accounts, + compressed_accounts, + system_accounts_offset, + LIGHT_CPI_SIGNER, + &crate::ID, + ) + .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) + } + }) +} + +// ============================================================================= +// COMPRESS INSTRUCTION ENTRYPOINT +// ============================================================================= + +pub fn generate_compress_instruction_entrypoint() -> Result { + Ok(syn::parse_quote! { + #[inline(never)] + #[allow(clippy::too_many_arguments)] + pub fn compress_accounts_idempotent<'info>( + ctx: Context<'_, '_, '_, 'info, CompressAccountsIdempotent<'info>>, + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + __processor_functions::process_compress_accounts_idempotent( + &ctx.accounts, + &ctx.remaining_accounts, + compressed_accounts, + system_accounts_offset, + ) + } + }) +} + +// ============================================================================= +// COMPRESS ACCOUNTS STRUCT +// ============================================================================= + +pub fn generate_compress_accounts_struct(variant: InstructionVariant) -> Result { + // Only Mixed variant is supported - PdaOnly and TokenOnly are not implemented + match variant { + InstructionVariant::PdaOnly | InstructionVariant::TokenOnly => { + unreachable!("compress_accounts_struct only supports Mixed variant") + } + InstructionVariant::Mixed => {} + } + + Ok(syn::parse_quote! { + #[derive(Accounts)] + pub struct CompressAccountsIdempotent<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + /// CHECK: Checked by SDK + pub config: AccountInfo<'info>, + /// CHECK: Checked by SDK + #[account(mut)] + pub rent_sponsor: AccountInfo<'info>, + /// CHECK: Checked by SDK + #[account(mut)] + pub compression_authority: AccountInfo<'info>, + } + }) +} + +// ============================================================================= +// VALIDATION AND ERROR CODES +// ============================================================================= + +#[inline(never)] +pub fn validate_compressed_account_sizes(account_types: &[Ident]) -> Result { + let size_checks: Vec<_> = account_types.iter().map(|account_type| { + quote! { + const _: () = { + const COMPRESSED_SIZE: usize = 8 + <#account_type as light_sdk::compressible::compression_info::CompressedInitSpace>::COMPRESSED_INIT_SPACE; + if COMPRESSED_SIZE > 800 { + panic!(concat!( + "Compressed account '", stringify!(#account_type), "' exceeds 800-byte compressible account size limit. If you need support for larger accounts, send a message to team@lightprotocol.com" + )); + } + }; + } + }).collect(); + + Ok(quote! { #(#size_checks)* }) +} + +#[inline(never)] +pub fn generate_error_codes(variant: InstructionVariant) -> Result { + // Only Mixed variant is supported - PdaOnly and TokenOnly are not implemented + match variant { + InstructionVariant::PdaOnly | InstructionVariant::TokenOnly => { + unreachable!("generate_error_codes only supports Mixed variant") + } + InstructionVariant::Mixed => {} + } + + Ok(quote! { + #[error_code] + pub enum RentFreeInstructionError { + #[msg("Rent sponsor mismatch")] + InvalidRentSponsor, + #[msg("Missing seed account")] + MissingSeedAccount, + #[msg("Seed value does not match account data")] + SeedMismatch, + #[msg("Not implemented")] + CTokenDecompressionNotImplemented, + #[msg("Not implemented")] + PdaDecompressionNotImplemented, + #[msg("Not implemented")] + TokenCompressionNotImplemented, + #[msg("Not implemented")] + PdaCompressionNotImplemented, + } + }) +} diff --git a/sdk-libs/macros/src/rentfree/program/crate_context.rs b/sdk-libs/macros/src/rentfree/program/crate_context.rs new file mode 100644 index 0000000000..db9191f28d --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/crate_context.rs @@ -0,0 +1,274 @@ +//! Anchor-style crate context parser for `#[rentfree_program]`. +//! +//! This module recursively reads all module files at macro expansion time, +//! allowing `#[rentfree_program]` to discover all `#[derive(RentFree)]` structs +//! across the entire crate. +//! +//! Based on Anchor's `CrateContext::parse()` pattern from `anchor-syn/src/parser/context.rs`. + +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +use syn::{Item, ItemStruct}; + +/// Context containing all parsed modules in the crate. +pub struct CrateContext { + modules: BTreeMap, +} + +impl CrateContext { + /// Parse all modules starting from the crate root (lib.rs or main.rs). + /// + /// Uses `CARGO_MANIFEST_DIR` environment variable to locate the crate root. + pub fn parse_from_manifest() -> syn::Result { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| { + syn::Error::new( + proc_macro2::Span::call_site(), + "CARGO_MANIFEST_DIR not set - cannot parse crate context", + ) + })?; + + let src_dir = PathBuf::from(&manifest_dir).join("src"); + + // Try lib.rs first, then main.rs + let root_file = if src_dir.join("lib.rs").exists() { + src_dir.join("lib.rs") + } else if src_dir.join("main.rs").exists() { + src_dir.join("main.rs") + } else { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!("Could not find lib.rs or main.rs in {:?}", src_dir), + )); + }; + + Self::parse(&root_file) + } + + /// Parse all modules starting from a specific root file. + pub fn parse(root: &Path) -> syn::Result { + let modules = ParsedModule::parse_recursive(root, "crate")?; + Ok(CrateContext { modules }) + } + + /// Iterate over all struct items in all parsed modules. + pub fn structs(&self) -> impl Iterator { + self.modules.values().flat_map(|module| module.structs()) + } + + /// Find structs that have a specific derive attribute (e.g., "RentFree"). + pub fn structs_with_derive(&self, derive_name: &str) -> Vec<&ItemStruct> { + self.structs() + .filter(|s| has_derive_attribute(&s.attrs, derive_name)) + .collect() + } + + /// Get a reference to a specific module by path (e.g., "crate::instruction_accounts"). + #[allow(dead_code)] + pub fn module(&self, path: &str) -> Option<&ParsedModule> { + self.modules.get(path) + } +} + +/// A parsed module containing its items. +pub struct ParsedModule { + /// Module name (e.g., "instruction_accounts") + #[allow(dead_code)] + name: String, + /// File path where this module is defined + #[allow(dead_code)] + file: PathBuf, + /// Full module path (e.g., "crate::instruction_accounts") + #[allow(dead_code)] + path: String, + /// All items in the module + items: Vec, +} + +impl ParsedModule { + /// Recursively parse all modules starting from a root file. + fn parse_recursive( + root: &Path, + module_path: &str, + ) -> syn::Result> { + let mut modules = BTreeMap::new(); + + // Read and parse the root file + let content = std::fs::read_to_string(root).map_err(|e| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("Failed to read {:?}: {}", root, e), + ) + })?; + + let file: syn::File = syn::parse_str(&content).map_err(|e| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("Failed to parse {:?}: {}", root, e), + ) + })?; + + let root_dir = root.parent().unwrap_or(Path::new(".")); + let root_name = root.file_stem().and_then(|s| s.to_str()).unwrap_or("root"); + + // Create the root module + let root_module = ParsedModule { + name: root_name.to_string(), + file: root.to_path_buf(), + path: module_path.to_string(), + items: file.items.clone(), + }; + modules.insert(module_path.to_string(), root_module); + + // Process each item for nested modules + for item in &file.items { + if let Item::Mod(item_mod) = item { + let mod_name = item_mod.ident.to_string(); + let child_path = format!("{}::{}", module_path, mod_name); + + if let Some((_, items)) = &item_mod.content { + // Inline module: mod foo { ... } + let inline_module = ParsedModule { + name: mod_name.clone(), + file: root.to_path_buf(), + path: child_path.clone(), + items: items.clone(), + }; + modules.insert(child_path, inline_module); + } else { + // External module: mod foo; - need to find the file + if let Some(mod_file) = find_module_file(root_dir, root_name, &mod_name) { + // Recursively parse the external module + let child_modules = Self::parse_recursive(&mod_file, &child_path)?; + modules.extend(child_modules); + } + // If file not found, silently skip (might be a cfg'd out module) + } + } + } + + Ok(modules) + } + + /// Get all struct items in this module. + fn structs(&self) -> impl Iterator { + self.items.iter().filter_map(|item| { + if let Item::Struct(s) = item { + Some(s) + } else { + None + } + }) + } +} + +/// Find the file for an external module declaration. +/// +/// Tries multiple paths following Rust module resolution: +/// - sibling_dir/mod_name.rs +/// - sibling_dir/mod_name/mod.rs +/// - parent_mod/mod_name.rs (if parent is a mod.rs) +/// - parent_mod/mod_name/mod.rs (if parent is a mod.rs) +fn find_module_file(parent_dir: &Path, parent_name: &str, mod_name: &str) -> Option { + // Standard paths relative to parent directory + let paths = vec![ + // sibling file: parent_dir/mod_name.rs + parent_dir.join(format!("{}.rs", mod_name)), + // directory module: parent_dir/mod_name/mod.rs + parent_dir.join(mod_name).join("mod.rs"), + ]; + + // If parent is mod.rs or lib.rs, also check parent_name directory + if parent_name == "mod" || parent_name == "lib" { + for path in &paths { + if path.exists() { + return Some(path.clone()); + } + } + } else { + // Parent is a regular file like foo.rs, check foo/mod_name.rs + let parent_mod_dir = parent_dir.join(parent_name); + let extra_paths = [ + parent_mod_dir.join(format!("{}.rs", mod_name)), + parent_mod_dir.join(mod_name).join("mod.rs"), + ]; + + for path in paths.iter().chain(extra_paths.iter()) { + if path.exists() { + return Some(path.clone()); + } + } + } + + // Check standard paths + for path in &paths { + if path.exists() { + return Some(path.clone()); + } + } + + None +} + +/// Check if a struct has a specific derive attribute. +fn has_derive_attribute(attrs: &[syn::Attribute], derive_name: &str) -> bool { + for attr in attrs { + if !attr.path().is_ident("derive") { + continue; + } + + // Parse the derive contents + if let Ok(nested) = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) { + for path in nested { + // Check simple ident: #[derive(RentFree)] + if let Some(ident) = path.get_ident() { + if ident == derive_name { + return true; + } + } + // Check path: #[derive(light_sdk::RentFree)] + if let Some(segment) = path.segments.last() { + if segment.ident == derive_name { + return true; + } + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_has_derive_attribute() { + let code = quote::quote! { + #[derive(Accounts, RentFree)] + pub struct CreateUser<'info> { + pub fee_payer: Signer<'info>, + } + }; + let item: ItemStruct = syn::parse2(code).unwrap(); + assert!(has_derive_attribute(&item.attrs, "RentFree")); + assert!(has_derive_attribute(&item.attrs, "Accounts")); + assert!(!has_derive_attribute(&item.attrs, "Clone")); + } + + #[test] + fn test_has_derive_attribute_qualified() { + let code = quote::quote! { + #[derive(light_sdk::RentFree)] + pub struct CreateUser<'info> { + pub fee_payer: Signer<'info>, + } + }; + let item: ItemStruct = syn::parse2(code).unwrap(); + assert!(has_derive_attribute(&item.attrs, "RentFree")); + } +} diff --git a/sdk-libs/macros/src/rentfree/program/decompress.rs b/sdk-libs/macros/src/rentfree/program/decompress.rs new file mode 100644 index 0000000000..e39768a6c0 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/decompress.rs @@ -0,0 +1,303 @@ +//! Decompress code generation. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Ident, Result}; + +use super::{ + expr_traversal::transform_expr_for_ctx_seeds, + parsing::{InstructionVariant, SeedElement, TokenSeedSpec}, + seed_utils::ctx_fields_to_set, + variant_enum::PdaCtxSeedInfo, +}; +use crate::rentfree::shared_utils::is_constant_identifier; + +// ============================================================================= +// DECOMPRESS CONTEXT IMPL +// ============================================================================= + +pub fn generate_decompress_context_impl( + pda_ctx_seeds: Vec, + token_variant_ident: Ident, +) -> Result { + let lifetime: syn::Lifetime = syn::parse_quote!('info); + + let trait_impl = + crate::rentfree::traits::decompress_context::generate_decompress_context_trait_impl( + pda_ctx_seeds, + token_variant_ident, + lifetime, + )?; + + Ok(syn::parse_quote! { + mod __decompress_context_impl { + use super::*; + + #trait_impl + } + }) +} + +// ============================================================================= +// DECOMPRESS PROCESSOR +// ============================================================================= + +pub fn generate_process_decompress_accounts_idempotent() -> Result { + Ok(syn::parse_quote! { + #[inline(never)] + pub fn process_decompress_accounts_idempotent<'info>( + accounts: &DecompressAccountsIdempotent<'info>, + remaining_accounts: &[solana_account_info::AccountInfo<'info>], + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + light_sdk::compressible::process_decompress_accounts_idempotent( + accounts, + remaining_accounts, + compressed_accounts, + proof, + system_accounts_offset, + LIGHT_CPI_SIGNER, + &crate::ID, + std::option::Option::None::<&()>, + ) + .map_err(|e: solana_program_error::ProgramError| -> anchor_lang::error::Error { e.into() }) + } + }) +} + +// ============================================================================= +// DECOMPRESS INSTRUCTION ENTRYPOINT +// ============================================================================= + +pub fn generate_decompress_instruction_entrypoint() -> Result { + Ok(syn::parse_quote! { + #[inline(never)] + pub fn decompress_accounts_idempotent<'info>( + ctx: Context<'_, '_, '_, 'info, DecompressAccountsIdempotent<'info>>, + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + __processor_functions::process_decompress_accounts_idempotent( + &ctx.accounts, + &ctx.remaining_accounts, + proof, + compressed_accounts, + system_accounts_offset, + ) + } + }) +} + +// ============================================================================= +// DECOMPRESS ACCOUNTS STRUCT +// ============================================================================= + +#[inline(never)] +pub fn generate_decompress_accounts_struct(variant: InstructionVariant) -> Result { + // Only Mixed variant is supported - PdaOnly and TokenOnly are not implemented + match variant { + InstructionVariant::PdaOnly | InstructionVariant::TokenOnly => { + unreachable!("decompress_accounts_struct only supports Mixed variant") + } + InstructionVariant::Mixed => {} + } + + let account_fields = vec![ + quote! { + #[account(mut)] + pub fee_payer: Signer<'info> + }, + quote! { + /// CHECK: Checked by SDK + pub config: AccountInfo<'info> + }, + quote! { + /// CHECK: anyone can pay + #[account(mut)] + pub rent_sponsor: UncheckedAccount<'info> + }, + quote! { + /// CHECK: optional - only needed if decompressing tokens + #[account(mut)] + pub ctoken_rent_sponsor: Option> + }, + quote! { + /// CHECK: + #[account(address = solana_pubkey::pubkey!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"))] + pub light_token_program: Option> + }, + quote! { + /// CHECK: + #[account(address = solana_pubkey::pubkey!("GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy"))] + pub ctoken_cpi_authority: Option> + }, + quote! { + /// CHECK: Checked by SDK + pub ctoken_config: Option> + }, + ]; + + let struct_def = quote! { + #[derive(Accounts)] + pub struct DecompressAccountsIdempotent<'info> { + #(#account_fields,)* + } + }; + + syn::parse2(struct_def) +} + +// ============================================================================= +// PDA SEED DERIVATION +// ============================================================================= + +/// Generate PDA seed derivation that uses CtxSeeds struct instead of DecompressAccountsIdempotent. +/// Maps ctx.field -> ctx_seeds.field (direct Pubkey access, no Option unwrapping needed) +#[inline(never)] +fn generate_pda_seed_derivation_for_trait_with_ctx_seeds( + spec: &TokenSeedSpec, + ctx_seed_fields: &[syn::Ident], +) -> Result { + let mut bindings: Vec = Vec::new(); + let mut seed_refs = Vec::new(); + + // Convert ctx_seed_fields to a set for quick lookup + let ctx_field_names = ctx_fields_to_set(ctx_seed_fields); + + for (i, seed) in spec.seeds.iter().enumerate() { + match seed { + SeedElement::Literal(lit) => { + let value = lit.value(); + seed_refs.push(quote! { #value.as_bytes() }); + } + SeedElement::Expression(expr) => { + // Handle byte string literals: b"seed" -> use directly (no .as_bytes()) + if let syn::Expr::Lit(lit_expr) = &**expr { + if let syn::Lit::ByteStr(byte_str) = &lit_expr.lit { + let bytes = byte_str.value(); + seed_refs.push(quote! { &[#(#bytes),*] }); + continue; + } + } + + // Handle uppercase constants + if let syn::Expr::Path(path_expr) = &**expr { + if let Some(ident) = path_expr.path.get_ident() { + let ident_str = ident.to_string(); + if is_constant_identifier(&ident_str) { + seed_refs.push( + quote! { { let __seed: &[u8] = crate::#ident.as_ref(); __seed } }, + ); + continue; + } + } + } + + let binding_name = + syn::Ident::new(&format!("seed_{}", i), proc_macro2::Span::call_site()); + let mapped_expr = transform_expr_for_ctx_seeds(expr, &ctx_field_names); + bindings.push(quote! { + let #binding_name = #mapped_expr; + }); + seed_refs.push(quote! { (#binding_name).as_ref() }); + } + } + } + + let indices: Vec = (0..seed_refs.len()).collect(); + + Ok(quote! { + #(#bindings)* + let seeds: &[&[u8]] = &[#(#seed_refs,)*]; + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, program_id); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + #( + seeds_vec.push(seeds[#indices].to_vec()); + )* + seeds_vec.push(vec![bump]); + Ok((seeds_vec, pda)) + }) +} + +// ============================================================================= +// PDA SEED PROVIDER IMPLS +// ============================================================================= + +#[inline(never)] +pub fn generate_pda_seed_provider_impls( + account_types: &[Ident], + pda_ctx_seeds: &[PdaCtxSeedInfo], + pda_seeds: &Option>, +) -> Result> { + let pda_seed_specs = pda_seeds.as_ref().ok_or_else(|| { + super::parsing::macro_error!( + account_types + .first() + .cloned() + .unwrap_or_else(|| syn::Ident::new("unknown", proc_macro2::Span::call_site())), + "No seed specifications provided" + ) + })?; + + let mut results = Vec::with_capacity(account_types.len()); + + for (name, ctx_info) in account_types.iter().zip(pda_ctx_seeds.iter()) { + let name_str = name.to_string(); + let spec = pda_seed_specs + .iter() + .find(|s| s.variant == name_str) + .ok_or_else(|| { + super::parsing::macro_error!( + name, + "No seed specification for account type '{}'", + name_str + ) + })?; + + let ctx_seeds_struct_name = format_ident!("{}CtxSeeds", name); + let ctx_fields = &ctx_info.ctx_seed_fields; + let ctx_fields_decl: Vec<_> = ctx_fields + .iter() + .map(|field| { + quote! { pub #field: solana_pubkey::Pubkey } + }) + .collect(); + + let ctx_seeds_struct = if ctx_fields.is_empty() { + quote! { + #[derive(Default)] + pub struct #ctx_seeds_struct_name; + } + } else { + quote! { + #[derive(Default)] + pub struct #ctx_seeds_struct_name { + #(#ctx_fields_decl),* + } + } + }; + + let seed_derivation = + generate_pda_seed_derivation_for_trait_with_ctx_seeds(spec, ctx_fields)?; + + results.push(quote! { + #ctx_seeds_struct + + impl light_sdk::compressible::PdaSeedDerivation<#ctx_seeds_struct_name, ()> for #name { + fn derive_pda_seeds_with_accounts( + &self, + program_id: &solana_pubkey::Pubkey, + ctx_seeds: &#ctx_seeds_struct_name, + _seed_params: &(), + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { + #seed_derivation + } + } + }); + } + + Ok(results) +} diff --git a/sdk-libs/macros/src/rentfree/program/expr_traversal.rs b/sdk-libs/macros/src/rentfree/program/expr_traversal.rs new file mode 100644 index 0000000000..59866d94b0 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/expr_traversal.rs @@ -0,0 +1,82 @@ +//! AST expression transformation utilities. +//! +//! This module provides expression transformation for converting field access patterns +//! used in seed derivation code generation. + +use std::collections::HashSet; + +use syn::Expr; + +use crate::rentfree::shared_utils::is_base_path; + +// ============================================================================= +// EXPRESSION TRANSFORMER +// ============================================================================= + +/// Transform expressions by replacing field access patterns. +/// +/// Used for converting: +/// - `data.field` -> `self.field` +/// - `ctx.field` -> `ctx_seeds.field` (if field is in ctx_field_names) +/// - `ctx.accounts.field` -> `ctx_seeds.field` +pub fn transform_expr_for_ctx_seeds(expr: &Expr, ctx_field_names: &HashSet) -> Expr { + match expr { + Expr::Field(field_expr) => { + let Some(syn::Member::Named(field_name)) = Some(&field_expr.member) else { + return expr.clone(); + }; + + // Check for ctx.accounts.field -> ctx_seeds.field + if let Expr::Field(nested_field) = &*field_expr.base { + if let syn::Member::Named(base_name) = &nested_field.member { + if base_name == "accounts" && is_base_path(&nested_field.base, "ctx") { + return syn::parse_quote! { ctx_seeds.#field_name }; + } + } + } + + // Check for data.field -> self.field or ctx.field -> ctx_seeds.field + if is_base_path(&field_expr.base, "data") { + return syn::parse_quote! { self.#field_name }; + } + if is_base_path(&field_expr.base, "ctx") + && ctx_field_names.contains(&field_name.to_string()) + { + return syn::parse_quote! { ctx_seeds.#field_name }; + } + + expr.clone() + } + Expr::MethodCall(method_call) => { + let mut new_call = method_call.clone(); + new_call.receiver = Box::new(transform_expr_for_ctx_seeds( + &method_call.receiver, + ctx_field_names, + )); + new_call.args = method_call + .args + .iter() + .map(|a| transform_expr_for_ctx_seeds(a, ctx_field_names)) + .collect(); + Expr::MethodCall(new_call) + } + Expr::Call(call_expr) => { + let mut new_call = call_expr.clone(); + new_call.args = call_expr + .args + .iter() + .map(|a| transform_expr_for_ctx_seeds(a, ctx_field_names)) + .collect(); + Expr::Call(new_call) + } + Expr::Reference(ref_expr) => { + let mut new_ref = ref_expr.clone(); + new_ref.expr = Box::new(transform_expr_for_ctx_seeds( + &ref_expr.expr, + ctx_field_names, + )); + Expr::Reference(new_ref) + } + _ => expr.clone(), + } +} diff --git a/sdk-libs/macros/src/rentfree/program/instructions.rs b/sdk-libs/macros/src/rentfree/program/instructions.rs new file mode 100644 index 0000000000..704635cc50 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/instructions.rs @@ -0,0 +1,514 @@ +//! Compressible instructions generation - orchestration module. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Ident, Item, ItemMod, Result}; + +// Re-export types from parsing for external use +pub use super::parsing::{ + extract_ctx_seed_fields, extract_data_seed_fields, InstructionDataSpec, InstructionVariant, + SeedElement, TokenSeedSpec, +}; +use super::{ + compress::{ + generate_compress_accounts_struct, generate_compress_context_impl, + generate_compress_instruction_entrypoint, generate_error_codes, + generate_process_compress_accounts_idempotent, validate_compressed_account_sizes, + }, + decompress::{ + generate_decompress_accounts_struct, generate_decompress_context_impl, + generate_decompress_instruction_entrypoint, generate_pda_seed_provider_impls, + generate_process_decompress_accounts_idempotent, + }, + parsing::{ + convert_classified_to_seed_elements, convert_classified_to_seed_elements_vec, + extract_context_and_params, macro_error, wrap_function_with_rentfree, + }, + variant_enum::PdaCtxSeedInfo, +}; +use crate::utils::to_snake_case; + +// ============================================================================= +// MAIN CODEGEN +// ============================================================================= + +/// Orchestrates all code generation for the rentfree module. +#[inline(never)] +fn codegen( + module: &mut ItemMod, + account_types: Vec, + pda_seeds: Option>, + token_seeds: Option>, + instruction_data: Vec, +) -> Result { + let size_validation_checks = validate_compressed_account_sizes(&account_types)?; + + let content = module.content.as_mut().unwrap(); + let ctoken_enum = if let Some(ref token_seed_specs) = token_seeds { + if !token_seed_specs.is_empty() { + super::variant_enum::generate_ctoken_account_variant_enum(token_seed_specs)? + } else { + crate::rentfree::traits::utils::generate_empty_ctoken_enum() + } + } else { + crate::rentfree::traits::utils::generate_empty_ctoken_enum() + }; + + if let Some(ref token_seed_specs) = token_seeds { + for spec in token_seed_specs { + if spec.authority.is_none() { + return Err(macro_error!( + &spec.variant, + "Token account '{}' must specify authority = for compression signing.", + spec.variant + )); + } + } + } + + let pda_ctx_seeds: Vec = pda_seeds + .as_ref() + .map(|specs| { + specs + .iter() + .map(|spec| { + let ctx_fields = extract_ctx_seed_fields(&spec.seeds); + PdaCtxSeedInfo::new(spec.variant.clone(), ctx_fields) + }) + .collect() + }) + .unwrap_or_default(); + + let account_type_refs: Vec<&Ident> = account_types.iter().collect(); + let enum_and_traits = super::variant_enum::compressed_account_variant_with_ctx_seeds( + &account_type_refs, + &pda_ctx_seeds, + )?; + + let seed_params_struct = quote! { + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize, Clone, Debug, Default)] + pub struct SeedParams; + }; + + let instruction_data_types: std::collections::HashMap = instruction_data + .iter() + .map(|spec| (spec.field_name.to_string(), &spec.field_type)) + .collect(); + + let seeds_structs_and_constructors: Vec = if let Some(ref pda_seed_specs) = + pda_seeds + { + pda_seed_specs + .iter() + .zip(pda_ctx_seeds.iter()) + .map(|(spec, ctx_info)| { + let type_name = &spec.variant; + let seeds_struct_name = format_ident!("{}Seeds", type_name); + let constructor_name = format_ident!("{}", to_snake_case(&type_name.to_string())); + let ctx_fields = &ctx_info.ctx_seed_fields; + let ctx_field_decls: Vec<_> = ctx_fields.iter().map(|field| { + quote! { pub #field: solana_pubkey::Pubkey } + }).collect(); + let data_fields = extract_data_seed_fields(&spec.seeds); + let data_field_decls: Vec<_> = data_fields.iter().filter_map(|field| { + let field_str = field.to_string(); + instruction_data_types.get(&field_str).map(|ty| { + quote! { pub #field: #ty } + }) + }).collect(); + let data_verifications: Vec<_> = data_fields.iter().map(|field| { + quote! { + if data.#field != seeds.#field { + return std::result::Result::Err(RentFreeInstructionError::SeedMismatch.into()); + } + } + }).collect(); + quote! { + #[derive(Clone, Debug)] + pub struct #seeds_struct_name { + #(#ctx_field_decls,)* + #(#data_field_decls,)* + } + impl RentFreeAccountVariant { + pub fn #constructor_name( + account_data: &[u8], + seeds: #seeds_struct_name, + ) -> std::result::Result { + use anchor_lang::AnchorDeserialize; + let data = #type_name::deserialize(&mut &account_data[..])?; + + #(#data_verifications)* + + std::result::Result::Ok(Self::#type_name { + data, + #(#ctx_fields: seeds.#ctx_fields,)* + }) + } + } + impl light_sdk::compressible::IntoVariant for #seeds_struct_name { + fn into_variant(self, data: &[u8]) -> std::result::Result { + RentFreeAccountVariant::#constructor_name(data, self) + } + } + } + }) + .collect() + } else { + Vec::new() + }; + + let has_pda_seeds = pda_seeds.as_ref().map(|p| !p.is_empty()).unwrap_or(false); + let has_token_seeds = token_seeds.as_ref().map(|t| !t.is_empty()).unwrap_or(false); + + let instruction_variant = match (has_pda_seeds, has_token_seeds) { + (true, true) => InstructionVariant::Mixed, + (true, false) => InstructionVariant::PdaOnly, + (false, true) => InstructionVariant::TokenOnly, + (false, false) => { + return Err(macro_error!( + module, + "At least one PDA or token seed specification must be provided" + )) + } + }; + + let error_codes = generate_error_codes(instruction_variant)?; + let decompress_accounts = generate_decompress_accounts_struct(instruction_variant)?; + + let pda_seed_provider_impls = + generate_pda_seed_provider_impls(&account_types, &pda_ctx_seeds, &pda_seeds)?; + + let trait_impls: syn::ItemMod = syn::parse_quote! { + mod __trait_impls { + use super::*; + + impl light_sdk::compressible::HasTokenVariant for RentFreeAccountData { + fn is_packed_token(&self) -> bool { + matches!(self.data, RentFreeAccountVariant::PackedCTokenData(_)) + } + } + } + }; + + let token_variant_name = format_ident!("TokenAccountVariant"); + + let decompress_context_impl = + generate_decompress_context_impl(pda_ctx_seeds.clone(), token_variant_name)?; + let decompress_processor_fn = generate_process_decompress_accounts_idempotent()?; + let decompress_instruction = generate_decompress_instruction_entrypoint()?; + + let compress_accounts = generate_compress_accounts_struct(instruction_variant)?; + let compress_context_impl = generate_compress_context_impl(account_types.clone())?; + let compress_processor_fn = generate_process_compress_accounts_idempotent()?; + let compress_instruction = generate_compress_instruction_entrypoint()?; + + let module_tokens = quote! { + mod __processor_functions { + use super::*; + #decompress_processor_fn + #compress_processor_fn + } + }; + let processor_module: syn::ItemMod = syn::parse2(module_tokens)?; + + let init_config_accounts: syn::ItemStruct = syn::parse_quote! { + #[derive(Accounts)] + pub struct InitializeCompressionConfig<'info> { + #[account(mut)] + pub payer: Signer<'info>, + /// CHECK: Checked by SDK + #[account(mut)] + pub config: AccountInfo<'info>, + /// CHECK: Checked by SDK + pub program_data: AccountInfo<'info>, + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, + } + }; + + let update_config_accounts: syn::ItemStruct = syn::parse_quote! { + #[derive(Accounts)] + pub struct UpdateCompressionConfig<'info> { + /// CHECK: Checked by SDK + #[account(mut)] + pub config: AccountInfo<'info>, + pub update_authority: Signer<'info>, + } + }; + + let init_config_instruction: syn::ItemFn = syn::parse_quote! { + #[inline(never)] + #[allow(clippy::too_many_arguments)] + pub fn initialize_compression_config<'info>( + ctx: Context<'_, '_, '_, 'info, InitializeCompressionConfig<'info>>, + write_top_up: u32, + rent_sponsor: Pubkey, + compression_authority: Pubkey, + rent_config: light_compressible::rent::RentConfig, + address_space: Vec, + ) -> Result<()> { + light_sdk::compressible::process_initialize_compression_config_checked( + &ctx.accounts.config.to_account_info(), + &ctx.accounts.authority.to_account_info(), + &ctx.accounts.program_data.to_account_info(), + &rent_sponsor, + &compression_authority, + rent_config, + write_top_up, + address_space, + 0, + &ctx.accounts.payer.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + &crate::ID, + )?; + Ok(()) + } + }; + + let update_config_instruction: syn::ItemFn = syn::parse_quote! { + #[inline(never)] + #[allow(clippy::too_many_arguments)] + pub fn update_compression_config<'info>( + ctx: Context<'_, '_, '_, 'info, UpdateCompressionConfig<'info>>, + new_rent_sponsor: Option, + new_compression_authority: Option, + new_rent_config: Option, + new_write_top_up: Option, + new_address_space: Option>, + new_update_authority: Option, + ) -> Result<()> { + light_sdk::compressible::process_update_compression_config( + ctx.accounts.config.as_ref(), + ctx.accounts.update_authority.as_ref(), + new_update_authority.as_ref(), + new_rent_sponsor.as_ref(), + new_compression_authority.as_ref(), + new_rent_config, + new_write_top_up, + new_address_space, + &crate::ID, + )?; + Ok(()) + } + }; + + let client_functions = super::seed_codegen::generate_client_seed_functions( + &account_types, + &pda_seeds, + &token_seeds, + &instruction_data, + )?; + + // Insert SeedParams struct + let seed_params_item: Item = syn::parse2(seed_params_struct)?; + content.1.push(seed_params_item); + + // Insert XxxSeeds structs and RentFreeAccountVariant constructors + for seeds_tokens in seeds_structs_and_constructors.into_iter() { + let wrapped: syn::File = syn::parse2(seeds_tokens)?; + for item in wrapped.items { + content.1.push(item); + } + } + + content.1.push(Item::Verbatim(size_validation_checks)); + content.1.push(Item::Verbatim(enum_and_traits)); + content.1.push(Item::Verbatim(ctoken_enum)); + content.1.push(Item::Struct(decompress_accounts)); + content.1.push(Item::Mod(trait_impls)); + content.1.push(Item::Mod(decompress_context_impl)); + content.1.push(Item::Mod(processor_module)); + content.1.push(Item::Fn(decompress_instruction)); + content.1.push(Item::Struct(compress_accounts)); + content.1.push(Item::Mod(compress_context_impl)); + content.1.push(Item::Fn(compress_instruction)); + content.1.push(Item::Struct(init_config_accounts)); + content.1.push(Item::Struct(update_config_accounts)); + content.1.push(Item::Fn(init_config_instruction)); + content.1.push(Item::Fn(update_config_instruction)); + + // Add pda seed provider impls + for pda_impl in pda_seed_provider_impls.into_iter() { + let wrapped: syn::File = syn::parse2(pda_impl)?; + for item in wrapped.items { + content.1.push(item); + } + } + + // Add ctoken seed provider impl + if let Some(ref seeds) = token_seeds { + if !seeds.is_empty() { + let impl_code = + super::seed_codegen::generate_ctoken_seed_provider_implementation(seeds)?; + let ctoken_impl: syn::ItemImpl = syn::parse2(impl_code)?; + content.1.push(Item::Impl(ctoken_impl)); + } + } + + // Add error codes + let error_item: syn::ItemEnum = syn::parse2(error_codes)?; + content.1.push(Item::Enum(error_item)); + + // Add client functions (module + pub use statement) + let client_file: syn::File = syn::parse2(client_functions)?; + for item in client_file.items { + content.1.push(item); + } + + Ok(quote! { #module }) +} + +// ============================================================================= +// MAIN ENTRY POINT +// ============================================================================= + +/// Main entry point for #[rentfree_program] macro. +/// +/// This macro reads external module files to extract seed information from +/// Accounts structs with #[rentfree] fields. It also automatically wraps +/// instruction handlers that use these Accounts structs with pre_init/finalize logic. +/// +/// Usage: +/// ```ignore +/// #[rentfree_program] +/// #[program] +/// pub mod my_program { +/// pub mod instruction_accounts; // Macro reads this file! +/// pub mod state; +/// +/// use instruction_accounts::*; +/// use state::*; +/// +/// // No #[light_instruction] needed - auto-wrapped! +/// pub fn create_user(ctx: Context, params: Params) -> Result<()> { +/// // Your business logic +/// } +/// } +/// ``` +#[inline(never)] +pub fn rentfree_program_impl(_args: TokenStream, mut module: ItemMod) -> Result { + use super::crate_context::CrateContext; + use crate::rentfree::traits::seed_extraction::{ + extract_from_accounts_struct, get_data_fields, ExtractedSeedSpec, ExtractedTokenSpec, + }; + + if module.content.is_none() { + return Err(macro_error!(&module, "Module must have a body")); + } + + // Parse the crate following mod declarations (Anchor-style) + let crate_ctx = CrateContext::parse_from_manifest()?; + + // Find all structs with #[derive(Accounts)] and extract rentfree field info + let mut pda_specs: Vec = Vec::new(); + let mut token_specs: Vec = Vec::new(); + let mut rentfree_struct_names = std::collections::HashSet::new(); + + for item_struct in crate_ctx.structs_with_derive("Accounts") { + if let Some(info) = extract_from_accounts_struct(item_struct)? { + if !info.pda_fields.is_empty() || !info.token_fields.is_empty() { + rentfree_struct_names.insert(info.struct_name.to_string()); + pda_specs.extend(info.pda_fields); + token_specs.extend(info.token_fields); + } + } + } + + // Check if we found anything + if pda_specs.is_empty() && token_specs.is_empty() { + return Err(macro_error!( + &module, + "No #[rentfree] or #[rentfree_token] fields found in any Accounts struct.\n\ + Ensure your Accounts structs are in modules declared with `pub mod xxx;`" + )); + } + + // Auto-wrap instruction handlers that use rentfree Accounts structs + if let Some((_, ref mut items)) = module.content { + for item in items.iter_mut() { + if let Item::Fn(fn_item) = item { + // Check if this function uses a rentfree Accounts struct + if let Some((context_type, params_ident)) = extract_context_and_params(fn_item) { + if rentfree_struct_names.contains(&context_type) { + // Wrap the function with pre_init/finalize logic + *fn_item = wrap_function_with_rentfree(fn_item, ¶ms_ident); + } + } + } + } + } + + // Convert extracted specs to the format expected by codegen + let mut found_pda_seeds: Vec = Vec::new(); + let mut found_data_fields: Vec = Vec::new(); + let mut account_types: Vec = Vec::new(); + + for pda in &pda_specs { + account_types.push(pda.inner_type.clone()); + + let seed_elements = convert_classified_to_seed_elements(&pda.seeds); + + // Extract data field types from seeds + for (field_name, conversion) in get_data_fields(&pda.seeds) { + let field_type: syn::Type = if conversion.is_some() { + syn::parse_quote!(u64) + } else { + syn::parse_quote!(solana_pubkey::Pubkey) + }; + + if !found_data_fields.iter().any(|f| f.field_name == field_name) { + found_data_fields.push(InstructionDataSpec { + field_name, + field_type, + }); + } + } + + found_pda_seeds.push(TokenSeedSpec { + variant: pda.variant_name.clone(), + _eq: syn::parse_quote!(=), + is_token: Some(false), + seeds: seed_elements, + authority: None, + }); + } + + // Convert token specs + let mut found_token_seeds: Vec = Vec::new(); + for token in &token_specs { + let seed_elements = convert_classified_to_seed_elements(&token.seeds); + let authority_elements = token + .authority_seeds + .as_ref() + .map(|seeds| convert_classified_to_seed_elements_vec(seeds)); + + found_token_seeds.push(TokenSeedSpec { + variant: token.variant_name.clone(), + _eq: syn::parse_quote!(=), + is_token: Some(true), + seeds: seed_elements, + authority: authority_elements, + }); + } + + let pda_seeds = if found_pda_seeds.is_empty() { + None + } else { + Some(found_pda_seeds) + }; + + let token_seeds = if found_token_seeds.is_empty() { + None + } else { + Some(found_token_seeds) + }; + + // Use the shared generation logic + codegen( + &mut module, + account_types, + pda_seeds, + token_seeds, + found_data_fields, + ) +} diff --git a/sdk-libs/macros/src/rentfree/program/mod.rs b/sdk-libs/macros/src/rentfree/program/mod.rs new file mode 100644 index 0000000000..5992ce18be --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/mod.rs @@ -0,0 +1,19 @@ +//! Rent-free program macro implementation. +//! +//! This module provides `#[rentfree_program]` attribute macro that: +//! - Automatically discovers #[rentfree] fields in Accounts structs +//! - Auto-wraps instruction handlers with light_pre_init/light_finalize logic +//! - Generates all necessary types, enums, and instruction handlers + +mod compress; +pub mod crate_context; +mod decompress; +pub mod expr_traversal; +pub mod instructions; +mod parsing; +pub mod seed_codegen; +pub mod seed_utils; +pub mod variant_enum; +pub mod visitors; + +pub use instructions::rentfree_program_impl; diff --git a/sdk-libs/macros/src/rentfree/program/parsing.rs b/sdk-libs/macros/src/rentfree/program/parsing.rs new file mode 100644 index 0000000000..6100fa3d95 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/parsing.rs @@ -0,0 +1,430 @@ +//! Parsing types, expression analysis, seed conversion, and function wrapping. + +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + Expr, Ident, ItemFn, LitStr, Result, Token, +}; + +use super::visitors::FieldExtractor; + +// ============================================================================= +// MACRO ERROR HELPER +// ============================================================================= + +macro_rules! macro_error { + ($span:expr, $msg:expr) => { + syn::Error::new_spanned( + $span, + format!( + "{}\n --> macro location: {}:{}", + $msg, + file!(), + line!() + ) + ) + }; + ($span:expr, $fmt:expr, $($arg:tt)*) => { + syn::Error::new_spanned( + $span, + format!( + concat!($fmt, "\n --> macro location: {}:{}"), + $($arg)*, + file!(), + line!() + ) + ) + }; +} + +pub(crate) use macro_error; + +// ============================================================================= +// CORE TYPES +// ============================================================================= + +#[derive(Debug, Clone, Copy)] +pub enum InstructionVariant { + PdaOnly, + TokenOnly, + Mixed, +} + +#[derive(Clone)] +pub struct TokenSeedSpec { + pub variant: Ident, + pub _eq: Token![=], + pub is_token: Option, + pub seeds: Punctuated, + pub authority: Option>, +} + +impl Parse for TokenSeedSpec { + fn parse(input: ParseStream) -> Result { + let variant: Ident = input.parse()?; + let _eq: Token![=] = input.parse()?; + + let content; + syn::parenthesized!(content in input); + + // New explicit syntax: + // PDA: TypeName = (seeds = (...)) + // Token: TypeName = (is_token, seeds = (...), authority = (...)) + let mut is_token = None; + let mut seeds = Punctuated::new(); + let mut authority = None; + + while !content.is_empty() { + if content.peek(Ident) { + let ident: Ident = content.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "is_token" | "true" => { + is_token = Some(true); + } + "is_pda" | "false" => { + is_token = Some(false); + } + "seeds" => { + let _eq: Token![=] = content.parse()?; + let seeds_content; + syn::parenthesized!(seeds_content in content); + seeds = parse_seed_elements(&seeds_content)?; + } + "authority" => { + let _eq: Token![=] = content.parse()?; + authority = Some(parse_authority_seeds(&content)?); + } + _ => { + return Err(syn::Error::new_spanned( + &ident, + format!( + "Unknown keyword '{}'. Expected: is_token, seeds, or authority.\n\ + Use explicit syntax: TypeName = (seeds = (\"seed\", ctx.account, ...))\n\ + For tokens: TypeName = (is_token, seeds = (...), authority = (...))", + ident_str + ), + )); + } + } + } else { + return Err(syn::Error::new( + content.span(), + "Expected keyword (is_token, seeds, or authority). Use explicit syntax:\n\ + - PDA: TypeName = (seeds = (\"seed\", ctx.account, ...))\n\ + - Token: TypeName = (is_token, seeds = (...), authority = (...))", + )); + } + + if content.peek(Token![,]) { + let _comma: Token![,] = content.parse()?; + } else { + break; + } + } + + if seeds.is_empty() { + return Err(syn::Error::new_spanned( + &variant, + format!( + "Missing seeds for '{}'. Use: {} = (seeds = (\"seed\", ctx.account, ...))", + variant, variant + ), + )); + } + + Ok(TokenSeedSpec { + variant, + _eq, + is_token, + seeds, + authority, + }) + } +} + +/// Parse seed elements from within seeds = (...) +fn parse_seed_elements(content: ParseStream) -> Result> { + let mut seeds = Punctuated::new(); + + while !content.is_empty() { + seeds.push(content.parse::()?); + + if content.peek(Token![,]) { + let _: Token![,] = content.parse()?; + if content.is_empty() { + break; + } + } else { + break; + } + } + + Ok(seeds) +} + +/// Parse authority seeds - either parenthesized tuple or single expression +fn parse_authority_seeds(content: ParseStream) -> Result> { + if content.peek(syn::token::Paren) { + let auth_content; + syn::parenthesized!(auth_content in content); + let mut auth_seeds = Vec::new(); + + while !auth_content.is_empty() { + auth_seeds.push(auth_content.parse::()?); + if auth_content.peek(Token![,]) { + let _: Token![,] = auth_content.parse()?; + } else { + break; + } + } + Ok(auth_seeds) + } else { + // Single expression (e.g., LIGHT_CPI_SIGNER) + Ok(vec![content.parse::()?]) + } +} + +#[derive(Clone)] +pub enum SeedElement { + Literal(LitStr), + Expression(Box), +} + +impl Parse for SeedElement { + fn parse(input: ParseStream) -> Result { + if input.peek(LitStr) { + Ok(SeedElement::Literal(input.parse()?)) + } else { + Ok(SeedElement::Expression(input.parse()?)) + } + } +} + +pub struct InstructionDataSpec { + pub field_name: Ident, + pub field_type: syn::Type, +} + +impl Parse for InstructionDataSpec { + fn parse(input: ParseStream) -> Result { + let field_name: Ident = input.parse()?; + let _eq: Token![=] = input.parse()?; + let field_type: syn::Type = input.parse()?; + + Ok(InstructionDataSpec { + field_name, + field_type, + }) + } +} + +// ============================================================================= +// EXPRESSION ANALYSIS +// ============================================================================= + +/// Extract all ctx.accounts.XXX and ctx.XXX field names from a list of seed elements. +/// Deduplicates the fields using visitor-based extraction. +pub fn extract_ctx_seed_fields( + seeds: &syn::punctuated::Punctuated, +) -> Vec { + let mut all_fields = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for seed in seeds { + if let SeedElement::Expression(expr) = seed { + let fields = FieldExtractor::ctx_fields(&[]).extract(expr); + for field in fields { + let name = field.to_string(); + if seen.insert(name) { + all_fields.push(field); + } + } + } + } + + all_fields +} + +/// Extract all data.XXX field names from a list of seed elements. +/// Deduplicates the fields using visitor-based extraction. +pub fn extract_data_seed_fields( + seeds: &syn::punctuated::Punctuated, +) -> Vec { + let mut all_fields = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for seed in seeds { + if let SeedElement::Expression(expr) = seed { + let fields = FieldExtractor::data_fields().extract(expr); + for field in fields { + let name = field.to_string(); + if seen.insert(name) { + all_fields.push(field); + } + } + } + } + + all_fields +} + +// ============================================================================= +// SEED CONVERSION +// ============================================================================= + +/// Convert ClassifiedSeed to SeedElement (Punctuated) +pub fn convert_classified_to_seed_elements( + seeds: &[crate::rentfree::traits::seed_extraction::ClassifiedSeed], +) -> Punctuated { + use crate::rentfree::traits::seed_extraction::ClassifiedSeed; + + let mut result = Punctuated::new(); + for seed in seeds { + let elem = match seed { + ClassifiedSeed::Literal(bytes) => { + // Convert to string literal + if let Ok(s) = std::str::from_utf8(bytes) { + SeedElement::Literal(syn::LitStr::new(s, proc_macro2::Span::call_site())) + } else { + // Byte array - use expression + let byte_values: Vec<_> = bytes.iter().map(|b| quote!(#b)).collect(); + let expr: Expr = syn::parse_quote!(&[#(#byte_values),*]); + SeedElement::Expression(Box::new(expr)) + } + } + ClassifiedSeed::Constant(path) => { + let expr: Expr = syn::parse_quote!(#path); + SeedElement::Expression(Box::new(expr)) + } + ClassifiedSeed::CtxAccount(ident) => { + let expr: Expr = syn::parse_quote!(ctx.#ident); + SeedElement::Expression(Box::new(expr)) + } + ClassifiedSeed::DataField { + field_name, + conversion: None, + } => { + let expr: Expr = syn::parse_quote!(data.#field_name); + SeedElement::Expression(Box::new(expr)) + } + ClassifiedSeed::DataField { + field_name, + conversion: Some(method), + } => { + let expr: Expr = syn::parse_quote!(data.#field_name.#method()); + SeedElement::Expression(Box::new(expr)) + } + ClassifiedSeed::FunctionCall { func, ctx_args } => { + let args: Vec = ctx_args + .iter() + .map(|arg| syn::parse_quote!(&ctx.#arg.key())) + .collect(); + let expr: Expr = syn::parse_quote!(#func(#(#args),*)); + SeedElement::Expression(Box::new(expr)) + } + }; + result.push(elem); + } + result +} + +pub fn convert_classified_to_seed_elements_vec( + seeds: &[crate::rentfree::traits::seed_extraction::ClassifiedSeed], +) -> Vec { + convert_classified_to_seed_elements(seeds) + .into_iter() + .collect() +} + +// ============================================================================= +// FUNCTION WRAPPING +// ============================================================================= + +/// Extract the Context type name from a function's parameters. +/// Returns (struct_name, params_ident) if found. +pub fn extract_context_and_params(fn_item: &ItemFn) -> Option<(String, Ident)> { + let mut context_type = None; + let mut params_ident = None; + + for input in &fn_item.sig.inputs { + if let syn::FnArg::Typed(pat_type) = input { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + // Check if this is a Context parameter + if let syn::Type::Path(type_path) = &*pat_type.ty { + if let Some(segment) = type_path.path.segments.last() { + if segment.ident == "Context" { + // Extract T from Context<'_, '_, '_, 'info, T<'info>> or Context + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + // Find the last type argument (T or T<'info>) + for arg in args.args.iter().rev() { + if let syn::GenericArgument::Type(syn::Type::Path(inner_path)) = + arg + { + if let Some(inner_seg) = inner_path.path.segments.last() { + context_type = Some(inner_seg.ident.to_string()); + break; + } + } + } + } + } + } + } + + // Track potential params argument (not ctx, not signer-like names) + let name = pat_ident.ident.to_string(); + if name != "ctx" && !name.contains("signer") && !name.contains("bump") { + // Prefer "params" but accept others + if name == "params" || params_ident.is_none() { + params_ident = Some(pat_ident.ident.clone()); + } + } + } + } + } + + match (context_type, params_ident) { + (Some(ctx), Some(params)) => Some((ctx, params)), + _ => None, + } +} + +/// Wrap a function with pre_init/finalize logic. +pub fn wrap_function_with_rentfree(fn_item: &ItemFn, params_ident: &Ident) -> ItemFn { + let fn_vis = &fn_item.vis; + let fn_sig = &fn_item.sig; + let fn_block = &fn_item.block; + let fn_attrs = &fn_item.attrs; + + let wrapped: ItemFn = syn::parse_quote! { + #(#fn_attrs)* + #fn_vis #fn_sig { + // Phase 1: Pre-init (creates mints via CPI context write, registers compressed addresses) + use light_sdk::compressible::{LightPreInit, LightFinalize}; + let __has_pre_init = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) + .map_err(|e| { + let pe: solana_program_error::ProgramError = e.into(); + pe + })?; + + // Execute the original handler body in a closure + let __light_handler_result = (|| #fn_block)(); + + // Phase 2: On success, finalize compression + if __light_handler_result.is_ok() { + ctx.accounts.light_finalize(ctx.remaining_accounts, &#params_ident, __has_pre_init) + .map_err(|e| { + let pe: solana_program_error::ProgramError = e.into(); + pe + })?; + } + + __light_handler_result + } + }; + + wrapped +} diff --git a/sdk-libs/macros/src/rentfree/program/seed_codegen.rs b/sdk-libs/macros/src/rentfree/program/seed_codegen.rs new file mode 100644 index 0000000000..121a2e73f6 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/seed_codegen.rs @@ -0,0 +1,253 @@ +//! Seed provider generation for PDA and Light Token accounts. + +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Ident, Result}; + +use super::{ + instructions::{InstructionDataSpec, TokenSeedSpec}, + seed_utils::{generate_seed_derivation_body, seed_element_to_ref_expr, SeedConversionConfig}, + variant_enum::extract_ctx_fields_from_token_spec, + visitors::{classify_seed, generate_client_seed_code}, +}; + +/// Phase 8: Generate TokenSeedProvider impl that uses self.field instead of ctx.accounts.field +pub fn generate_ctoken_seed_provider_implementation( + token_seeds: &[TokenSeedSpec], +) -> Result { + let mut get_seeds_match_arms = Vec::new(); + let mut get_authority_seeds_match_arms = Vec::new(); + + let config = SeedConversionConfig::for_ctoken_provider(); + + for spec in token_seeds { + let variant_name = &spec.variant; + let ctx_fields = extract_ctx_fields_from_token_spec(spec); + + // Build match pattern with destructuring if there are ctx fields + let pattern = if ctx_fields.is_empty() { + quote! { TokenAccountVariant::#variant_name } + } else { + let field_names: Vec<_> = ctx_fields.iter().collect(); + quote! { TokenAccountVariant::#variant_name { #(#field_names,)* } } + }; + + // Build seed refs for get_seeds - use self.field directly for ctx.* seeds + let token_seed_refs: Vec = spec + .seeds + .iter() + .map(|s| seed_element_to_ref_expr(s, &config)) + .collect(); + + let get_seeds_arm = quote! { + #pattern => { + let seeds: &[&[u8]] = &[#(#token_seed_refs),*]; + let (token_account_pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, program_id); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(vec![bump]); + Ok((seeds_vec, token_account_pda)) + } + }; + get_seeds_match_arms.push(get_seeds_arm); + + // Build authority seeds + if let Some(authority_seeds) = &spec.authority { + let auth_seed_refs: Vec = authority_seeds + .iter() + .map(|s| seed_element_to_ref_expr(s, &config)) + .collect(); + + let authority_arm = quote! { + #pattern => { + let seeds: &[&[u8]] = &[#(#auth_seed_refs),*]; + let (authority_pda, bump) = solana_pubkey::Pubkey::find_program_address(seeds, program_id); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(vec![bump]); + Ok((seeds_vec, authority_pda)) + } + }; + get_authority_seeds_match_arms.push(authority_arm); + } else { + let authority_arm = quote! { + #pattern => { + Err(solana_program_error::ProgramError::Custom( + RentFreeInstructionError::MissingSeedAccount.into() + )) + } + }; + get_authority_seeds_match_arms.push(authority_arm); + } + } + + // Phase 8: New trait signature - no ctx/accounts parameter needed + Ok(quote! { + impl light_sdk::compressible::TokenSeedProvider for TokenAccountVariant { + fn get_seeds( + &self, + program_id: &solana_pubkey::Pubkey, + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { + match self { + #(#get_seeds_match_arms)* + } + } + + fn get_authority_seeds( + &self, + program_id: &solana_pubkey::Pubkey, + ) -> std::result::Result<(Vec>, solana_pubkey::Pubkey), solana_program_error::ProgramError> { + match self { + #(#get_authority_seeds_match_arms)* + } + } + } + }) +} + +#[inline(never)] +pub fn generate_client_seed_functions( + _account_types: &[Ident], + pda_seeds: &Option>, + token_seeds: &Option>, + instruction_data: &[InstructionDataSpec], +) -> Result { + let mut functions = Vec::new(); + + if let Some(pda_seed_specs) = pda_seeds { + for spec in pda_seed_specs { + let variant_name = &spec.variant; + let snake_case = camel_to_snake_case(&variant_name.to_string()); + let function_name = format_ident!("get_{}_seeds", snake_case); + + let (parameters, seed_expressions) = + analyze_seed_spec_for_client(spec, instruction_data)?; + + let fn_body = generate_seed_derivation_body(&seed_expressions, quote! { &crate::ID }); + let function = quote! { + pub fn #function_name(#(#parameters),*) -> (Vec>, solana_pubkey::Pubkey) { + #fn_body + } + }; + functions.push(function); + } + } + + if let Some(token_seed_specs) = token_seeds { + for spec in token_seed_specs { + let variant_name = &spec.variant; + + let function_name = + format_ident!("get_{}_seeds", variant_name.to_string().to_lowercase()); + + let (parameters, seed_expressions) = + analyze_seed_spec_for_client(spec, instruction_data)?; + + let fn_body = generate_seed_derivation_body(&seed_expressions, quote! { &crate::ID }); + let function = quote! { + pub fn #function_name(#(#parameters),*) -> (Vec>, solana_pubkey::Pubkey) { + #fn_body + } + }; + functions.push(function); + + if let Some(authority_seeds) = &spec.authority { + let authority_function_name = format_ident!( + "get_{}_authority_seeds", + variant_name.to_string().to_lowercase() + ); + + let mut authority_spec = TokenSeedSpec { + variant: spec.variant.clone(), + _eq: spec._eq, + is_token: spec.is_token, + seeds: syn::punctuated::Punctuated::new(), + authority: None, + }; + + for auth_seed in authority_seeds { + authority_spec.seeds.push(auth_seed.clone()); + } + + let (auth_parameters, auth_seed_expressions) = + analyze_seed_spec_for_client(&authority_spec, instruction_data)?; + + let (fn_params, fn_body) = if auth_parameters.is_empty() { + ( + quote! { _program_id: &solana_pubkey::Pubkey }, + generate_seed_derivation_body( + &auth_seed_expressions, + quote! { _program_id }, + ), + ) + } else { + ( + quote! { #(#auth_parameters),* }, + generate_seed_derivation_body( + &auth_seed_expressions, + quote! { &crate::ID }, + ), + ) + }; + let authority_function = quote! { + pub fn #authority_function_name(#fn_params) -> (Vec>, solana_pubkey::Pubkey) { + #fn_body + } + }; + functions.push(authority_function); + } + } + } + + Ok(quote! { + mod __client_seed_functions { + use super::*; + #(#functions)* + } + + pub use __client_seed_functions::*; + }) +} + +/// Analyze a seed spec and generate client function parameters and seed expressions. +/// +/// Uses the classification-based approach: first classify each seed, then generate code. +/// This separates "what kind of seed is this?" from "what code to generate?". +#[inline(never)] +fn analyze_seed_spec_for_client( + spec: &TokenSeedSpec, + instruction_data: &[InstructionDataSpec], +) -> Result<(Vec, Vec)> { + let mut parameters = Vec::new(); + let mut expressions = Vec::new(); + let mut seen_params = HashSet::new(); + + for seed in &spec.seeds { + // Phase 1: Classification + let info = classify_seed(seed)?; + + // Phase 2: Code generation (modifies parameters and expressions in place) + generate_client_seed_code( + &info, + instruction_data, + &mut seen_params, + &mut parameters, + &mut expressions, + )?; + } + + Ok((parameters, expressions)) +} + +fn camel_to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(c.to_lowercase().next().unwrap()); + } + result +} diff --git a/sdk-libs/macros/src/rentfree/program/seed_utils.rs b/sdk-libs/macros/src/rentfree/program/seed_utils.rs new file mode 100644 index 0000000000..93b875bb6b --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/seed_utils.rs @@ -0,0 +1,122 @@ +//! Seed expression conversion and derivation utilities. +//! +//! This module provides reusable utilities for: +//! - Converting SeedElement to TokenStream expressions +//! - Generating seed derivation code +//! - Extracting context fields from seed specifications + +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::Ident; + +use super::parsing::SeedElement; +use crate::rentfree::shared_utils::is_constant_identifier; + +// ============================================================================= +// SEED EXPRESSION CONVERSION +// ============================================================================= + +/// Configuration for seed expression conversion. +#[derive(Clone, Debug, Default)] +pub struct SeedConversionConfig { + /// Handle LIGHT_CPI_SIGNER specially with .cpi_signer.as_ref() + pub handle_light_cpi_signer: bool, + /// Map ctx.* to destructured field names (use field directly instead of ctx.field) + pub map_ctx_to_destructured: bool, +} + +impl SeedConversionConfig { + /// Config for ctoken seed provider (destructures ctx fields). + pub fn for_ctoken_provider() -> Self { + Self { + handle_light_cpi_signer: true, + map_ctx_to_destructured: true, + } + } +} + +/// Convert a SeedElement to a TokenStream representing the seed reference expression. +pub fn seed_element_to_ref_expr(seed: &SeedElement, config: &SeedConversionConfig) -> TokenStream { + match seed { + SeedElement::Literal(lit) => { + let value = lit.value(); + quote! { #value.as_bytes() } + } + SeedElement::Expression(expr) => { + // Handle byte string literals + if let syn::Expr::Lit(lit_expr) = &**expr { + if let syn::Lit::ByteStr(byte_str) = &lit_expr.lit { + let bytes = byte_str.value(); + return quote! { &[#(#bytes),*] }; + } + } + + // Handle uppercase constants + if let syn::Expr::Path(path_expr) = &**expr { + if let Some(ident) = path_expr.path.get_ident() { + let ident_str = ident.to_string(); + if is_constant_identifier(&ident_str) { + if config.handle_light_cpi_signer && ident_str == "LIGHT_CPI_SIGNER" { + return quote! { crate::#ident.cpi_signer.as_ref() }; + } else { + return quote! { { let __seed: &[u8] = crate::#ident.as_ref(); __seed } }; + } + } + } + } + + // Handle ctx.accounts.field or ctx.field + if config.map_ctx_to_destructured { + if let Some(field_name) = extract_ctx_field_name(expr) { + return quote! { #field_name.as_ref() }; + } + } + + // Fallback + quote! { (#expr).as_ref() } + } + } +} + +/// Extract the field name from a ctx.field or ctx.accounts.field expression. +/// +/// Uses the visitor-based FieldExtractor for clean pattern matching. +fn extract_ctx_field_name(expr: &syn::Expr) -> Option { + let fields = super::visitors::FieldExtractor::ctx_fields(&[]).extract(expr); + fields.into_iter().next() +} + +// ============================================================================= +// SEED DERIVATION GENERATION +// ============================================================================= + +/// Generate the body of a seed function that computes a PDA address. +/// +/// Returns code that: +/// 1. Builds seed_values Vec +/// 2. Computes PDA with find_program_address +/// 3. Appends bump to seeds +/// 4. Returns (seeds_vec, pda) +pub fn generate_seed_derivation_body( + seed_expressions: &[TokenStream], + program_id_expr: TokenStream, +) -> TokenStream { + let seed_count = seed_expressions.len(); + quote! { + let mut seed_values = Vec::with_capacity(#seed_count + 1); + #( + seed_values.push((#seed_expressions).to_vec()); + )* + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = solana_pubkey::Pubkey::find_program_address(&seed_slices, #program_id_expr); + seed_values.push(vec![bump]); + (seed_values, pda) + } +} + +/// Build set of ctx field names from identifiers. +pub fn ctx_fields_to_set(fields: &[Ident]) -> HashSet { + fields.iter().map(|f| f.to_string()).collect() +} diff --git a/sdk-libs/macros/src/compressible/variant_enum.rs b/sdk-libs/macros/src/rentfree/program/variant_enum.rs similarity index 67% rename from sdk-libs/macros/src/compressible/variant_enum.rs rename to sdk-libs/macros/src/rentfree/program/variant_enum.rs index 88aa578c36..be3d109e90 100644 --- a/sdk-libs/macros/src/compressible/variant_enum.rs +++ b/sdk-libs/macros/src/rentfree/program/variant_enum.rs @@ -2,6 +2,8 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{Ident, Result}; +use super::parsing::{SeedElement, TokenSeedSpec}; + /// Info about ctx.* seeds for a PDA type #[derive(Clone, Debug)] pub struct PdaCtxSeedInfo { @@ -333,3 +335,183 @@ pub fn compressed_account_variant_with_ctx_seeds( Ok(expanded) } + +// ============================================================================= +// TOKEN ACCOUNT VARIANT +// ============================================================================= + +/// Extract ctx.* field names from seed elements (both token seeds and authority seeds). +/// +/// Uses the visitor-based FieldExtractor for clean AST traversal. +pub fn extract_ctx_fields_from_token_spec(spec: &TokenSeedSpec) -> Vec { + const EXCLUDED: &[&str] = &[ + "fee_payer", + "rent_sponsor", + "config", + "compression_authority", + ]; + + let mut all_fields = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for seed in spec.seeds.iter().chain(spec.authority.iter().flatten()) { + if let SeedElement::Expression(expr) = seed { + // Extract fields from this expression using the visitor + let fields = super::visitors::FieldExtractor::ctx_fields(EXCLUDED).extract(expr); + // Deduplicate across seeds + for field in fields { + let name = field.to_string(); + if seen.insert(name) { + all_fields.push(field); + } + } + } + } + + all_fields +} + +/// Generate TokenAccountVariant and PackedTokenAccountVariant enums with Pack/Unpack impls. +pub fn generate_ctoken_account_variant_enum(token_seeds: &[TokenSeedSpec]) -> Result { + let unpacked_variants = 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,)* }, } + } + }); + + let packed_variants = 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,)* }, } + } + }); + + let pack_arms = token_seeds.iter().map(|spec| { + let variant_name = &spec.variant; + let ctx_fields = extract_ctx_fields_from_token_spec(spec); + + if ctx_fields.is_empty() { + quote! { + TokenAccountVariant::#variant_name => PackedTokenAccountVariant::#variant_name, + } + } else { + let field_bindings: Vec<_> = ctx_fields.iter().collect(); + let idx_fields: Vec<_> = ctx_fields + .iter() + .map(|f| format_ident!("{}_idx", f)) + .collect(); + let pack_stmts: Vec<_> = ctx_fields + .iter() + .zip(idx_fields.iter()) + .map(|(field, idx)| { + quote! { let #idx = remaining_accounts.insert_or_get(*#field); } + }) + .collect(); + + quote! { + TokenAccountVariant::#variant_name { #(#field_bindings,)* } => { + #(#pack_stmts)* + PackedTokenAccountVariant::#variant_name { #(#idx_fields,)* } + } + } + } + }); + + let unpack_arms = token_seeds.iter().map(|spec| { + let variant_name = &spec.variant; + let ctx_fields = extract_ctx_fields_from_token_spec(spec); + + if ctx_fields.is_empty() { + quote! { + PackedTokenAccountVariant::#variant_name => Ok(TokenAccountVariant::#variant_name), + } + } else { + let idx_fields: Vec<_> = ctx_fields + .iter() + .map(|f| format_ident!("{}_idx", f)) + .collect(); + let unpack_stmts: Vec<_> = ctx_fields + .iter() + .zip(idx_fields.iter()) + .map(|(field, idx)| { + quote! { + let #field = *remaining_accounts + .get(*#idx as usize) + .ok_or(solana_program_error::ProgramError::InvalidAccountData)? + .key; + } + }) + .collect(); + let field_names: Vec<_> = ctx_fields.iter().collect(); + + quote! { + PackedTokenAccountVariant::#variant_name { #(#idx_fields,)* } => { + #(#unpack_stmts)* + Ok(TokenAccountVariant::#variant_name { #(#field_names,)* }) + } + } + } + }); + + Ok(quote! { + #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] + pub enum TokenAccountVariant { + #(#unpacked_variants)* + } + + #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] + pub enum PackedTokenAccountVariant { + #(#packed_variants)* + } + + impl light_token_sdk::pack::Pack for TokenAccountVariant { + type Packed = PackedTokenAccountVariant; + + fn pack(&self, remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + match self { + #(#pack_arms)* + } + } + } + + impl light_token_sdk::pack::Unpack for PackedTokenAccountVariant { + type Unpacked = TokenAccountVariant; + + fn unpack( + &self, + remaining_accounts: &[solana_account_info::AccountInfo], + ) -> std::result::Result { + match self { + #(#unpack_arms)* + } + } + } + + impl light_sdk::compressible::IntoCTokenVariant for TokenAccountVariant { + fn into_ctoken_variant(self, token_data: light_token_sdk::compat::TokenData) -> RentFreeAccountVariant { + RentFreeAccountVariant::CTokenData(light_token_sdk::compat::CTokenData { + variant: self, + token_data, + }) + } + } + }) +} diff --git a/sdk-libs/macros/src/rentfree/program/visitors.rs b/sdk-libs/macros/src/rentfree/program/visitors.rs new file mode 100644 index 0000000000..1b4528d27a --- /dev/null +++ b/sdk-libs/macros/src/rentfree/program/visitors.rs @@ -0,0 +1,586 @@ +//! Visitor-based AST traversal utilities using syn's Visit trait. +//! +//! This module provides: +//! - `FieldExtractor`: A visitor for extracting field names from expressions +//! - `ClientSeedInfo`: Classification of seed elements for client code generation +//! - `classify_seed`: Classify a seed element into a `ClientSeedInfo` +//! - `generate_client_seed_code`: Generate (parameter, expression) from classified seed +//! +//! The implementation stores references during traversal to avoid allocations, +//! only cloning identifiers when producing the final output. + +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + visit::{self, Visit}, + Expr, Ident, Member, +}; + +use super::instructions::{InstructionDataSpec, SeedElement}; +use crate::rentfree::{shared_utils::is_constant_identifier, traits::utils::is_pubkey_type}; + +/// Visitor that extracts field names matching ctx.field, ctx.accounts.field, or data.field patterns. +/// +/// Uses syn's Visit trait for efficient read-only traversal. Stores references during +/// traversal to minimize allocations, only cloning when producing final output. +/// +/// # Example +/// ```ignore +/// let fields = FieldExtractor::ctx_fields(&["fee_payer"]) +/// .extract(&some_expr); +/// ``` +pub struct FieldExtractor<'ast, 'cfg> { + /// Extract ctx.field and ctx.accounts.field patterns + extract_ctx: bool, + /// Extract data.field patterns + extract_data: bool, + /// Field names to exclude from results + excluded: &'cfg [&'cfg str], + /// Collected field references (avoids cloning during traversal) + fields: Vec<&'ast Ident>, + /// Track seen field names for deduplication + seen: HashSet, +} + +impl<'ast, 'cfg> FieldExtractor<'ast, 'cfg> { + /// Create an extractor for ctx.field and ctx.accounts.field patterns. + /// + /// Excludes common infrastructure fields like fee_payer, rent_sponsor, etc. + pub fn ctx_fields(excluded: &'cfg [&'cfg str]) -> Self { + Self { + extract_ctx: true, + extract_data: false, + excluded, + fields: Vec::new(), + seen: HashSet::new(), + } + } + + /// Create an extractor for data.field patterns. + pub fn data_fields() -> Self { + Self { + extract_ctx: false, + extract_data: true, + excluded: &[], + fields: Vec::new(), + seen: HashSet::new(), + } + } + + /// Extract field names from the given expression. + /// + /// Visits the expression tree and collects all field names matching the configured patterns. + /// Returns deduplicated field identifiers in order of first occurrence. + /// Cloning is deferred until this final output stage. + pub fn extract(mut self, expr: &'ast Expr) -> Vec { + self.visit_expr(expr); + // Clone only when producing final output + self.fields.into_iter().cloned().collect() + } + + /// Try to add a field reference if not excluded and not already seen. + fn try_add(&mut self, field: &'ast Ident) { + let name = field.to_string(); + if !self.excluded.contains(&name.as_str()) && self.seen.insert(name) { + self.fields.push(field); + } + } + + /// Check if the base expression is `ctx.accounts`. + pub fn is_ctx_accounts(base: &Expr) -> bool { + if let Expr::Field(nested) = base { + if let Member::Named(member) = &nested.member { + return member == "accounts" && Self::is_path_ident(&nested.base, "ctx"); + } + } + false + } + + /// Check if an expression is a path with the given identifier. + fn is_path_ident(expr: &Expr, ident: &str) -> bool { + matches!(expr, Expr::Path(p) if p.path.is_ident(ident)) + } +} + +impl<'ast, 'cfg> Visit<'ast> for FieldExtractor<'ast, 'cfg> { + fn visit_expr_field(&mut self, node: &'ast syn::ExprField) { + if let Member::Named(field_name) = &node.member { + // Check for ctx.accounts.field pattern + if self.extract_ctx && Self::is_ctx_accounts(&node.base) { + self.try_add(field_name); + // Don't recurse further - we found our target + return; + } + + // Check for ctx.field pattern (direct access) + if self.extract_ctx && Self::is_path_ident(&node.base, "ctx") { + self.try_add(field_name); + return; + } + + // Check for data.field pattern + if self.extract_data && Self::is_path_ident(&node.base, "data") { + self.try_add(field_name); + return; + } + } + + // Continue visiting child expressions + visit::visit_expr_field(self, node); + } +} + +// ============================================================================= +// CLIENT SEED CLASSIFICATION +// ============================================================================= + +/// Classified seed for client code generation. +/// +/// Separates "what kind of seed is this?" from "what code to generate?". +/// Each variant captures all info needed for the generation phase. +#[derive(Debug, Clone)] +pub enum ClientSeedInfo { + /// String literal: "seed" -> "seed".as_bytes() + Literal(String), + /// Byte literal: b"seed" -> &[...] + ByteLiteral(Vec), + /// Constant: SEED -> crate::SEED.as_ref() + Constant { name: Ident, is_cpi_signer: bool }, + /// ctx.field or ctx.accounts.field -> Pubkey parameter + CtxField { field: Ident, method: Option }, + /// data.field -> typed parameter from instruction_data + DataField { field: Ident, method: Option }, + /// Function call - stored as original expression for proper AST transformation + FunctionCall(Box), + /// Raw identifier that becomes a Pubkey parameter + Identifier(Ident), + /// Fallback for expressions that don't match other patterns + RawExpr(Box), +} + +/// Classify a SeedElement for client code generation. +pub fn classify_seed(seed: &SeedElement) -> syn::Result { + match seed { + SeedElement::Literal(lit) => Ok(ClientSeedInfo::Literal(lit.value())), + SeedElement::Expression(expr) => classify_seed_expr(expr), + } +} + +/// Classify an expression into a ClientSeedInfo variant. +fn classify_seed_expr(expr: &syn::Expr) -> syn::Result { + match expr { + syn::Expr::Field(field_expr) => classify_field_expr(field_expr), + syn::Expr::MethodCall(method_call) => classify_method_call(method_call), + syn::Expr::Lit(lit_expr) => classify_lit_expr(lit_expr), + syn::Expr::Path(path_expr) => classify_path_expr(path_expr), + syn::Expr::Call(call_expr) => classify_call_expr(call_expr), + syn::Expr::Reference(ref_expr) => classify_seed_expr(&ref_expr.expr), + _ => Ok(ClientSeedInfo::RawExpr(Box::new(expr.clone()))), + } +} + +/// Classify a field expression (e.g., ctx.field, data.field). +fn classify_field_expr(field_expr: &syn::ExprField) -> syn::Result { + if let Member::Named(field_name) = &field_expr.member { + // Check for ctx.accounts.field pattern + if FieldExtractor::is_ctx_accounts(&field_expr.base) { + return Ok(ClientSeedInfo::CtxField { + field: field_name.clone(), + method: None, + }); + } + + // Check for direct base.field patterns + if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "data" { + return Ok(ClientSeedInfo::DataField { + field: field_name.clone(), + method: None, + }); + } + if segment.ident == "ctx" { + return Ok(ClientSeedInfo::CtxField { + field: field_name.clone(), + method: None, + }); + } + } + } + + // Unrecognized field pattern - preserve as raw expression + // This lets downstream code decide how to handle it + return Ok(ClientSeedInfo::RawExpr(Box::new(syn::Expr::Field( + field_expr.clone(), + )))); + } + + Ok(ClientSeedInfo::RawExpr(Box::new(syn::Expr::Field( + field_expr.clone(), + )))) +} + +/// Classify a method call expression (e.g., data.field.method(), ctx.accounts.field.key()). +fn classify_method_call(method_call: &syn::ExprMethodCall) -> syn::Result { + // Check if receiver is a field expression + if let syn::Expr::Field(field_expr) = &*method_call.receiver { + if let Member::Named(field_name) = &field_expr.member { + // Check for ctx.accounts.field.method() pattern + if FieldExtractor::is_ctx_accounts(&field_expr.base) { + return Ok(ClientSeedInfo::CtxField { + field: field_name.clone(), + method: Some(method_call.method.clone()), + }); + } + + // Check for direct base.field patterns + if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "data" { + return Ok(ClientSeedInfo::DataField { + field: field_name.clone(), + method: Some(method_call.method.clone()), + }); + } + if segment.ident == "ctx" { + return Ok(ClientSeedInfo::CtxField { + field: field_name.clone(), + method: Some(method_call.method.clone()), + }); + } + } + } + } + } + + // Check if receiver is a path identifier (e.g., ident.as_ref()) + if let syn::Expr::Path(path_expr) = &*method_call.receiver { + if let Some(ident) = path_expr.path.get_ident() { + return Ok(ClientSeedInfo::Identifier(ident.clone())); + } + } + + Ok(ClientSeedInfo::RawExpr(Box::new(syn::Expr::MethodCall( + method_call.clone(), + )))) +} + +/// Classify a literal expression. +fn classify_lit_expr(lit_expr: &syn::ExprLit) -> syn::Result { + if let syn::Lit::ByteStr(byte_str) = &lit_expr.lit { + Ok(ClientSeedInfo::ByteLiteral(byte_str.value())) + } else { + Ok(ClientSeedInfo::RawExpr(Box::new(syn::Expr::Lit( + lit_expr.clone(), + )))) + } +} + +/// Classify a path expression (constant or identifier). +fn classify_path_expr(path_expr: &syn::ExprPath) -> syn::Result { + if let Some(ident) = path_expr.path.get_ident() { + let ident_str = ident.to_string(); + if is_constant_identifier(&ident_str) { + return Ok(ClientSeedInfo::Constant { + name: ident.clone(), + is_cpi_signer: ident_str == "LIGHT_CPI_SIGNER", + }); + } + return Ok(ClientSeedInfo::Identifier(ident.clone())); + } + Ok(ClientSeedInfo::RawExpr(Box::new(syn::Expr::Path( + path_expr.clone(), + )))) +} + +/// Classify a function call expression. +/// We store the original expression because function call arguments need AST transformation, +/// not simple classification - we need to preserve method calls like `.key()` while mapping +/// base identifiers to parameters. +fn classify_call_expr(call_expr: &syn::ExprCall) -> syn::Result { + Ok(ClientSeedInfo::FunctionCall(Box::new(call_expr.clone()))) +} + +// ============================================================================= +// CLIENT CODE GENERATION +// ============================================================================= + +/// Map a function call argument, extracting parameters and transforming ctx/data references. +/// +/// This preserves the expression structure (method calls, references) while replacing +/// `ctx.field`, `ctx.accounts.field`, and `data.field` with just the field name. +/// Returns the transformed expression and collects parameters into the provided vectors. +fn map_call_arg( + arg: &syn::Expr, + instruction_data: &[InstructionDataSpec], + seen_params: &mut HashSet, + parameters: &mut Vec, +) -> syn::Result { + match arg { + syn::Expr::Reference(ref_expr) => { + let inner = map_call_arg(&ref_expr.expr, instruction_data, seen_params, parameters)?; + Ok(quote! { &#inner }) + } + syn::Expr::Field(field_expr) => { + if let syn::Member::Named(field_name) = &field_expr.member { + // Check for ctx.accounts.field + if FieldExtractor::is_ctx_accounts(&field_expr.base) { + if seen_params.insert(field_name.to_string()) { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + } + return Ok(quote! { #field_name }); + } + // Check for ctx.field or data.field + if let syn::Expr::Path(path) = &*field_expr.base { + if let Some(segment) = path.path.segments.first() { + if segment.ident == "data" { + if let Some(data_spec) = instruction_data + .iter() + .find(|d| d.field_name == *field_name) + { + if seen_params.insert(field_name.to_string()) { + let param_type = &data_spec.field_type; + let param_with_ref = if is_pubkey_type(param_type) { + quote! { #field_name: &#param_type } + } else { + quote! { #field_name: #param_type } + }; + parameters.push(param_with_ref); + } + return Ok(quote! { #field_name }); + } + } else if segment.ident == "ctx" { + if seen_params.insert(field_name.to_string()) { + parameters.push(quote! { #field_name: &solana_pubkey::Pubkey }); + } + return Ok(quote! { #field_name }); + } + } + } + } + Ok(quote! { #field_expr }) + } + syn::Expr::MethodCall(method_call) => { + let receiver = map_call_arg( + &method_call.receiver, + instruction_data, + seen_params, + parameters, + )?; + let method = &method_call.method; + let args: Vec = method_call + .args + .iter() + .map(|a| map_call_arg(a, instruction_data, seen_params, parameters)) + .collect::>()?; + Ok(quote! { (#receiver).#method(#(#args),*) }) + } + syn::Expr::Call(nested_call) => { + let func = &nested_call.func; + let args: Vec = nested_call + .args + .iter() + .map(|a| map_call_arg(a, instruction_data, seen_params, parameters)) + .collect::>()?; + Ok(quote! { (#func)(#(#args),*) }) + } + syn::Expr::Path(path_expr) => { + // Check if this is a simple identifier that should become a parameter + if let Some(ident) = path_expr.path.get_ident() { + let name = ident.to_string(); + if name != "ctx" + && name != "data" + && !is_constant_identifier(&name) + && seen_params.insert(name) + { + parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); + } + } + Ok(quote! { #path_expr }) + } + _ => Ok(quote! { #arg }), + } +} + +/// Generate code for a classified seed, adding to the provided parameter and expression lists. +/// +/// This modifies the parameters and expressions vectors directly rather than returning +/// individual values, which allows for proper handling of function call seeds that may +/// contribute multiple parameters. +pub fn generate_client_seed_code( + info: &ClientSeedInfo, + instruction_data: &[InstructionDataSpec], + seen_params: &mut HashSet, + parameters: &mut Vec, + expressions: &mut Vec, +) -> syn::Result<()> { + match info { + ClientSeedInfo::Literal(s) => { + expressions.push(quote! { #s.as_bytes() }); + } + + ClientSeedInfo::ByteLiteral(bytes) => { + expressions.push(quote! { &[#(#bytes),*] }); + } + + ClientSeedInfo::Constant { + name, + is_cpi_signer, + } => { + let expr = if *is_cpi_signer { + quote! { crate::#name.cpi_signer.as_ref() } + } else { + quote! { { let __seed: &[u8] = crate::#name.as_ref(); __seed } } + }; + expressions.push(expr); + } + + ClientSeedInfo::CtxField { field, method } => { + if seen_params.insert(field.to_string()) { + parameters.push(quote! { #field: &solana_pubkey::Pubkey }); + } + let expr = match method { + Some(m) => quote! { #field.#m().as_ref() }, + None => quote! { #field.as_ref() }, + }; + expressions.push(expr); + } + + ClientSeedInfo::DataField { field, method } => { + let data_spec = instruction_data + .iter() + .find(|d| d.field_name == *field) + .ok_or_else(|| { + syn::Error::new( + field.span(), + format!("data.{} used in seeds but no type specified", field), + ) + })?; + + if seen_params.insert(field.to_string()) { + let param_type = &data_spec.field_type; + let param_with_ref = if is_pubkey_type(param_type) { + quote! { #field: &#param_type } + } else { + quote! { #field: #param_type } + }; + parameters.push(param_with_ref); + } + + let expr = match method { + Some(m) => quote! { #field.#m().as_ref() }, + None => quote! { #field.as_ref() }, + }; + expressions.push(expr); + } + + ClientSeedInfo::Identifier(ident) => { + if seen_params.insert(ident.to_string()) { + parameters.push(quote! { #ident: &solana_pubkey::Pubkey }); + } + expressions.push(quote! { #ident.as_ref() }); + } + + ClientSeedInfo::FunctionCall(call_expr) => { + let mut mapped_args: Vec = Vec::new(); + for arg in &call_expr.args { + let mapped = map_call_arg(arg, instruction_data, seen_params, parameters)?; + mapped_args.push(mapped); + } + let func = &call_expr.func; + expressions.push(quote! { (#func)(#(#mapped_args),*).as_ref() }); + } + + ClientSeedInfo::RawExpr(expr) => { + expressions.push(quote! { (#expr).as_ref() }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_ctx_accounts_field() { + let expr: Expr = syn::parse_quote!(ctx.accounts.user); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); + } + + #[test] + fn test_extract_ctx_direct_field() { + let expr: Expr = syn::parse_quote!(ctx.program_id); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "program_id"); + } + + #[test] + fn test_extract_data_field() { + let expr: Expr = syn::parse_quote!(data.owner); + let fields = FieldExtractor::data_fields().extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "owner"); + } + + #[test] + fn test_extract_nested_in_method_call() { + let expr: Expr = syn::parse_quote!(ctx.accounts.user.key()); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); + } + + #[test] + fn test_extract_nested_in_reference() { + let expr: Expr = syn::parse_quote!(&ctx.accounts.user.key()); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); + } + + #[test] + fn test_excludes_fields() { + let expr: Expr = syn::parse_quote!(ctx.accounts.fee_payer); + let fields = FieldExtractor::ctx_fields(&["fee_payer"]).extract(&expr); + assert!(fields.is_empty()); + } + + #[test] + fn test_deduplicates_fields() { + let expr: Expr = syn::parse_quote!({ + ctx.accounts.user.key(); + ctx.accounts.user.owner(); + }); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); + } + + #[test] + fn test_extract_from_call_args() { + let expr: Expr = syn::parse_quote!(some_fn(&ctx.accounts.user, data.amount)); + let ctx_fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + let data_fields = FieldExtractor::data_fields().extract(&expr); + assert_eq!(ctx_fields.len(), 1); + assert_eq!(ctx_fields[0].to_string(), "user"); + assert_eq!(data_fields.len(), 1); + assert_eq!(data_fields[0].to_string(), "amount"); + } + + #[test] + fn test_separate_extractors_for_ctx_and_data() { + let expr: Expr = syn::parse_quote!((ctx.accounts.user, data.amount)); + let ctx_fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + let data_fields = FieldExtractor::data_fields().extract(&expr); + assert_eq!(ctx_fields.len(), 1); + assert_eq!(ctx_fields[0].to_string(), "user"); + assert_eq!(data_fields.len(), 1); + assert_eq!(data_fields[0].to_string(), "amount"); + } +} diff --git a/sdk-libs/macros/src/rentfree/shared_utils.rs b/sdk-libs/macros/src/rentfree/shared_utils.rs new file mode 100644 index 0000000000..a9550b5adf --- /dev/null +++ b/sdk-libs/macros/src/rentfree/shared_utils.rs @@ -0,0 +1,85 @@ +//! Shared utilities for rentfree macro implementation. +//! +//! This module provides common utility functions used across multiple files: +//! - Constant identifier detection (SCREAMING_SNAKE_CASE) +//! - Expression identifier extraction + +use syn::{Expr, Ident}; + +/// Check if an identifier string is a constant (SCREAMING_SNAKE_CASE). +/// +/// Returns true if the string is non-empty and all characters are uppercase letters, +/// underscores, or ASCII digits. +/// +/// # Examples +/// ```ignore +/// assert!(is_constant_identifier("MY_CONSTANT")); +/// assert!(is_constant_identifier("SEED_123")); +/// assert!(!is_constant_identifier("myVariable")); +/// assert!(!is_constant_identifier("")); +/// ``` +#[inline] +pub fn is_constant_identifier(ident: &str) -> bool { + !ident.is_empty() + && ident + .chars() + .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) +} + +/// Extract the terminal identifier from an expression. +/// +/// This handles various expression patterns: +/// - `Path`: Returns the identifier directly +/// - `Field`: Returns the field name +/// - `MethodCall`: Recursively extracts from receiver +/// - `Reference`: Recursively extracts from referenced expression +/// +/// If `key_method_only` is true, only returns an identifier from MethodCall +/// expressions where the method is `key`. +#[inline] +pub fn extract_terminal_ident(expr: &Expr, key_method_only: bool) -> Option { + match expr { + Expr::Path(path) => path.path.get_ident().cloned(), + Expr::Field(field) => { + if let syn::Member::Named(name) = &field.member { + Some(name.clone()) + } else { + None + } + } + Expr::MethodCall(mc) => { + if key_method_only && mc.method != "key" { + None + } else { + extract_terminal_ident(&mc.receiver, key_method_only) + } + } + Expr::Reference(r) => extract_terminal_ident(&r.expr, key_method_only), + _ => None, + } +} + +/// Check if an expression is a path starting with the given base identifier. +/// +/// Used to check patterns like `ctx.field` where base would be "ctx". +#[inline] +pub fn is_base_path(expr: &Expr, base: &str) -> bool { + matches!(expr, Expr::Path(p) if p.path.segments.first().is_some_and(|s| s.ident == base)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_constant_identifier() { + assert!(is_constant_identifier("MY_CONSTANT")); + assert!(is_constant_identifier("SEED")); + assert!(is_constant_identifier("SEED_123")); + assert!(is_constant_identifier("A")); + assert!(!is_constant_identifier("myVariable")); + assert!(!is_constant_identifier("my_variable")); + assert!(!is_constant_identifier("MyConstant")); + assert!(!is_constant_identifier("")); + } +} diff --git a/sdk-libs/macros/src/compressible/decompress_context.rs b/sdk-libs/macros/src/rentfree/traits/decompress_context.rs similarity index 99% rename from sdk-libs/macros/src/compressible/decompress_context.rs rename to sdk-libs/macros/src/rentfree/traits/decompress_context.rs index cdccdfe8a3..44690279f5 100644 --- a/sdk-libs/macros/src/compressible/decompress_context.rs +++ b/sdk-libs/macros/src/rentfree/traits/decompress_context.rs @@ -5,7 +5,7 @@ use quote::{format_ident, quote}; use syn::{Ident, Result}; // Re-export from variant_enum for convenience -pub use crate::compressible::variant_enum::PdaCtxSeedInfo; +pub use crate::rentfree::program::variant_enum::PdaCtxSeedInfo; pub fn generate_decompress_context_trait_impl( pda_ctx_seeds: Vec, diff --git a/sdk-libs/macros/src/compressible/light_compressible.rs b/sdk-libs/macros/src/rentfree/traits/light_compressible.rs similarity index 94% rename from sdk-libs/macros/src/compressible/light_compressible.rs rename to sdk-libs/macros/src/rentfree/traits/light_compressible.rs index 95f38f8fe5..bc0863f072 100644 --- a/sdk-libs/macros/src/compressible/light_compressible.rs +++ b/sdk-libs/macros/src/rentfree/traits/light_compressible.rs @@ -11,9 +11,9 @@ use quote::quote; use syn::{DeriveInput, Fields, ItemStruct, Result}; use crate::{ - compressible::{pack_unpack::derive_compressible_pack, traits::derive_compressible}, discriminator::discriminator, hasher::derive_light_hasher_sha, + rentfree::traits::{pack_unpack::derive_compressible_pack, traits::derive_compressible}, }; /// Derives all required traits for a compressible account. @@ -53,7 +53,7 @@ use crate::{ /// /// - The `compression_info` field is auto-detected and handled specially (no `#[skip]` needed) /// - SHA256 hashing serializes the entire struct, so `#[hash]` is not needed -pub fn derive_light_compressible(input: DeriveInput) -> Result { +pub fn derive_rentfree_account(input: DeriveInput) -> Result { // Convert DeriveInput to ItemStruct for macros that need it let item_struct = derive_input_to_item_struct(&input)?; @@ -128,7 +128,7 @@ mod tests { } }; - let result = derive_light_compressible(input); + let result = derive_rentfree_account(input); assert!(result.is_ok(), "LightCompressible should succeed"); let output = result.unwrap().to_string(); @@ -181,7 +181,7 @@ mod tests { } }; - let result = derive_light_compressible(input); + let result = derive_rentfree_account(input); assert!( result.is_ok(), "LightCompressible with compress_as should succeed" @@ -203,7 +203,7 @@ mod tests { } }; - let result = derive_light_compressible(input); + let result = derive_rentfree_account(input); assert!( result.is_ok(), "LightCompressible without Pubkey fields should succeed" @@ -235,7 +235,7 @@ mod tests { } }; - let result = derive_light_compressible(input); + let result = derive_rentfree_account(input); assert!(result.is_err(), "LightCompressible should fail for enums"); } @@ -248,7 +248,7 @@ mod tests { } }; - let result = derive_light_compressible(input); + let result = derive_rentfree_account(input); // Compressible derive validates compression_info field assert!( result.is_err(), diff --git a/sdk-libs/macros/src/rentfree/traits/mod.rs b/sdk-libs/macros/src/rentfree/traits/mod.rs new file mode 100644 index 0000000000..1909b7fa04 --- /dev/null +++ b/sdk-libs/macros/src/rentfree/traits/mod.rs @@ -0,0 +1,17 @@ +//! Shared trait derive macros for compressible accounts. +//! +//! This module provides: +//! - `seed_extraction` - Seed extraction from Anchor account attributes +//! - `decompress_context` - Decompression context utilities +//! - `light_compressible` - Combined RentFreeAccount derive macro +//! - `pack_unpack` - Pack/Unpack trait implementations +//! - `traits` - HasCompressionInfo, Compressible, CompressAs traits +//! - `utils` - Shared utility functions + +pub mod decompress_context; +pub mod light_compressible; +pub mod pack_unpack; +pub mod seed_extraction; +#[allow(clippy::module_inception)] +pub mod traits; +pub mod utils; diff --git a/sdk-libs/macros/src/compressible/pack_unpack.rs b/sdk-libs/macros/src/rentfree/traits/pack_unpack.rs similarity index 100% rename from sdk-libs/macros/src/compressible/pack_unpack.rs rename to sdk-libs/macros/src/rentfree/traits/pack_unpack.rs diff --git a/sdk-libs/macros/src/compressible/anchor_seeds.rs b/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs similarity index 87% rename from sdk-libs/macros/src/compressible/anchor_seeds.rs rename to sdk-libs/macros/src/rentfree/traits/seed_extraction.rs index f80596a6eb..f24be467bf 100644 --- a/sdk-libs/macros/src/compressible/anchor_seeds.rs +++ b/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs @@ -5,6 +5,11 @@ use syn::{Expr, Ident, ItemStruct, Type}; +use crate::{ + rentfree::shared_utils::{extract_terminal_ident, is_constant_identifier}, + utils::snake_to_camel_case, +}; + /// Classified seed element from Anchor's seeds array #[derive(Clone, Debug)] pub enum ClassifiedSeed { @@ -73,7 +78,6 @@ pub fn extract_from_accounts_struct( let mut pda_fields = Vec::new(); let mut token_fields = Vec::new(); - let mut all_fields = Vec::new(); for field in fields { let field_ident = match &field.ident { @@ -81,8 +85,6 @@ pub fn extract_from_accounts_struct( None => continue, }; - all_fields.push((field_ident.clone(), field.ty.clone())); - // Check for #[rentfree] attribute let has_rentfree = field .attrs @@ -94,7 +96,8 @@ pub fn extract_from_accounts_struct( if has_rentfree { // Extract inner type from Account<'info, T> or Box> - let (is_boxed, inner_type) = match extract_account_inner_type(&field.ty) { + // Note: is_boxed is not needed for ExtractedSeedSpec, only inner_type + let (_, inner_type) = match extract_account_inner_type(&field.ty) { Some(result) => result, None => { return Err(syn::Error::new_spanned( @@ -113,7 +116,6 @@ pub fn extract_from_accounts_struct( Ident::new(&camel, field_ident.span()) }; - let _ = (field_ident, is_boxed); // Suppress unused warnings pda_fields.push(ExtractedSeedSpec { variant_name, inner_type, @@ -160,26 +162,26 @@ pub fn extract_from_accounts_struct( ]; for candidate in &authority_candidates { - if let Some((auth_field, _)) = all_fields.iter().find(|(name, _)| name == candidate) { - token.authority_field = Some(auth_field.clone()); - - // Try to extract authority seeds from the authority field - if let Some(auth_field_info) = fields - .iter() - .find(|f| f.ident.as_ref().map(|i| i.to_string()) == Some(candidate.clone())) - { + // Search fields directly instead of using a separate all_fields collection + if let Some(auth_field_info) = fields + .iter() + .find(|f| f.ident.as_ref().map(|i| i.to_string()) == Some(candidate.clone())) + { + if let Some(auth_ident) = &auth_field_info.ident { + token.authority_field = Some(auth_ident.clone()); + + // Try to extract authority seeds from the authority field if let Ok(auth_seeds) = extract_anchor_seeds(&auth_field_info.attrs) { if !auth_seeds.is_empty() { token.authority_seeds = Some(auth_seeds); } } + break; } - break; } } } - let _ = all_fields; // Suppress unused warning Ok(Some(ExtractedAccountsInfo { struct_name: item.ident.clone(), pda_fields, @@ -194,20 +196,6 @@ struct RentFreeTokenAttr { authority_seeds: Option>, } -/// Convert snake_case field name to CamelCase variant name -/// e.g., token_0_vault -> Token0Vault, vault -> Vault -fn snake_to_camel_case(s: &str) -> String { - s.split('_') - .map(|part| { - let mut chars = part.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().chain(chars).collect(), - } - }) - .collect() -} - /// Extract #[rentfree_token(authority = [...])] attribute /// Variant name is now derived from field name, not specified in attribute fn extract_rentfree_token_attr(attrs: &[syn::Attribute]) -> Option { @@ -312,7 +300,7 @@ fn parse_rentfree_token_list(tokens: &proc_macro2::TokenStream) -> syn::Result, Box>, /// AccountLoader<'info, T>, or InterfaceAccount<'info, T> -fn extract_account_inner_type(ty: &Type) -> Option<(bool, Ident)> { +pub fn extract_account_inner_type(ty: &Type) -> Option<(bool, Ident)> { match ty { Type::Path(type_path) => { let segment = type_path.path.segments.last()?; @@ -339,6 +327,16 @@ fn extract_account_inner_type(ty: &Type) -> Option<(bool, Ident)> { // Check for Box> if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + // Check for nested Box> which is not supported + if let Type::Path(inner_path) = inner_ty { + if let Some(inner_seg) = inner_path.path.segments.last() { + if inner_seg.ident == "Box" { + // Nested Box detected - return None to signal unsupported type + return None; + } + } + } + if let Some((_, inner_type)) = extract_account_inner_type(inner_ty) { return Some((true, inner_type)); } @@ -354,7 +352,7 @@ fn extract_account_inner_type(ty: &Type) -> Option<(bool, Ident)> { } /// Extract seeds from #[account(seeds = [...], bump)] attribute -fn extract_anchor_seeds(attrs: &[syn::Attribute]) -> syn::Result> { +pub fn extract_anchor_seeds(attrs: &[syn::Attribute]) -> syn::Result> { for attr in attrs { if !attr.path().is_ident("account") { continue; @@ -439,7 +437,7 @@ fn classify_seeds_array(expr: &Expr) -> syn::Result> { } /// Classify a single seed expression -fn classify_seed_expr(expr: &Expr) -> syn::Result { +pub fn classify_seed_expr(expr: &Expr) -> syn::Result { match expr { // b"literal" Expr::Lit(lit) => { @@ -458,11 +456,7 @@ fn classify_seed_expr(expr: &Expr) -> syn::Result { // CONSTANT (all uppercase path) Expr::Path(path) => { if let Some(ident) = path.path.get_ident() { - let name = ident.to_string(); - if name - .chars() - .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) - { + if is_constant_identifier(&ident.to_string()) { return Ok(ClassifiedSeed::Constant(path.path.clone())); } // Otherwise it's a variable reference - treat as ctx account @@ -514,7 +508,7 @@ fn classify_seed_expr(expr: &Expr) -> syn::Result { let mut ctx_args = Vec::new(); for arg in &call.args { - if let Some(ident) = extract_ctx_ident_from_expr(arg) { + if let Some(ident) = extract_terminal_ident(arg, true) { ctx_args.push(ident); } } @@ -550,7 +544,7 @@ fn classify_method_call(mc: &syn::ExprMethodCall) -> syn::Result // Handle account.key() if mc.method == "key" { - if let Some(ident) = extract_receiver_ident(&mc.receiver) { + if let Some(ident) = extract_terminal_ident(&mc.receiver, false) { // Check if it's params.field or ctx.account if let Expr::Field(field) = &*mc.receiver { if let Expr::Path(path) = &*field.base { @@ -600,46 +594,6 @@ fn extract_params_field(expr: &Expr) -> Option<(Ident, String)> { None } -/// Extract the base identifier from an expression like account.key() -> account -fn extract_receiver_ident(expr: &Expr) -> Option { - match expr { - Expr::Path(path) => path.path.get_ident().cloned(), - Expr::Field(field) => { - if let syn::Member::Named(name) = &field.member { - Some(name.clone()) - } else { - None - } - } - Expr::MethodCall(mc) => extract_receiver_ident(&mc.receiver), - Expr::Reference(r) => extract_receiver_ident(&r.expr), - _ => None, - } -} - -/// Extract ctx account identifier from expression (for function args) -fn extract_ctx_ident_from_expr(expr: &Expr) -> Option { - match expr { - Expr::Reference(r) => extract_ctx_ident_from_expr(&r.expr), - Expr::MethodCall(mc) => { - if mc.method == "key" { - extract_receiver_ident(&mc.receiver) - } else { - None - } - } - Expr::Field(field) => { - if let syn::Member::Named(name) = &field.member { - Some(name.clone()) - } else { - None - } - } - Expr::Path(path) => path.path.get_ident().cloned(), - _ => None, - } -} - /// Get data field names from classified seeds pub fn get_data_fields(seeds: &[ClassifiedSeed]) -> Vec<(Ident, Option)> { let mut fields = Vec::new(); diff --git a/sdk-libs/macros/src/compressible/traits.rs b/sdk-libs/macros/src/rentfree/traits/traits.rs similarity index 81% rename from sdk-libs/macros/src/compressible/traits.rs rename to sdk-libs/macros/src/rentfree/traits/traits.rs index 4118b5b17e..f7b5413dad 100644 --- a/sdk-libs/macros/src/compressible/traits.rs +++ b/sdk-libs/macros/src/rentfree/traits/traits.rs @@ -1,40 +1,45 @@ //! Trait derivation for compressible accounts. +use darling::FromMeta; use proc_macro2::TokenStream; use quote::quote; -use syn::{ - parse::{Parse, ParseStream}, - punctuated::Punctuated, - DeriveInput, Expr, Field, Ident, ItemStruct, Result, Token, -}; +use syn::{punctuated::Punctuated, DeriveInput, Expr, Field, Ident, ItemStruct, Result, Token}; use super::utils::{ extract_fields_from_derive_input, extract_fields_from_item_struct, is_copy_type, }; -struct CompressAsFields { - fields: Punctuated, -} - +/// A single field override in #[compress_as(field = expr)] struct CompressAsField { name: Ident, value: Expr, } -impl Parse for CompressAsField { - fn parse(input: ParseStream) -> Result { - let name: Ident = input.parse()?; - input.parse::()?; - let value: Expr = input.parse()?; - Ok(CompressAsField { name, value }) - } +/// Collection of field overrides parsed from #[compress_as(...)] +/// Uses darling's FromMeta to collect arbitrary name=value pairs. +#[derive(Default)] +struct CompressAsFields { + fields: Vec, } -impl Parse for CompressAsFields { - fn parse(input: ParseStream) -> Result { - Ok(CompressAsFields { - fields: Punctuated::parse_terminated(input)?, - }) +impl FromMeta for CompressAsFields { + fn from_list(items: &[darling::ast::NestedMeta]) -> darling::Result { + items + .iter() + .map(|item| match item { + darling::ast::NestedMeta::Meta(syn::Meta::NameValue(nv)) => { + let name = nv.path.get_ident().cloned().ok_or_else(|| { + darling::Error::custom("expected field identifier").with_span(&nv.path) + })?; + Ok(CompressAsField { + name, + value: nv.value.clone(), + }) + } + other => Err(darling::Error::custom("expected field = expr").with_span(other)), + }) + .collect::>>() + .map(|fields| CompressAsFields { fields }) } } @@ -105,18 +110,11 @@ fn generate_compress_as_field_assignments( continue; } - let has_override = compress_as_fields + let override_field = compress_as_fields .as_ref() - .is_some_and(|cas| cas.fields.iter().any(|f| &f.name == field_name)); - - if has_override { - let override_value = compress_as_fields - .as_ref() - .unwrap() - .fields - .iter() - .find(|f| &f.name == field_name) - .unwrap(); + .and_then(|cas| cas.fields.iter().find(|f| &f.name == field_name)); + + if let Some(override_value) = override_field { let value = &override_value.value; field_assignments.push(quote! { #field_name: #value, @@ -213,7 +211,9 @@ pub fn derive_compress_as(input: ItemStruct) -> Result { .find(|attr| attr.path().is_ident("compress_as")); let compress_as_fields = if let Some(attr) = compress_as_attr { - Some(attr.parse_args::()?) + let parsed = CompressAsFields::from_meta(&attr.meta) + .map_err(|e| syn::Error::new_spanned(attr, e.to_string()))?; + Some(parsed) } else { None }; @@ -234,14 +234,16 @@ pub fn derive_compressible(input: DeriveInput) -> Result { let struct_name = &input.ident; let fields = extract_fields_from_derive_input(&input)?; - // Extract compress_as attribute + // Extract compress_as attribute using darling let compress_as_attr = input .attrs .iter() .find(|attr| attr.path().is_ident("compress_as")); let compress_as_fields = if let Some(attr) = compress_as_attr { - Some(attr.parse_args::()?) + let parsed = CompressAsFields::from_meta(&attr.meta) + .map_err(|e| syn::Error::new_spanned(attr, e.to_string()))?; + Some(parsed) } else { None }; diff --git a/sdk-libs/macros/src/compressible/utils.rs b/sdk-libs/macros/src/rentfree/traits/utils.rs similarity index 100% rename from sdk-libs/macros/src/compressible/utils.rs rename to sdk-libs/macros/src/rentfree/traits/utils.rs diff --git a/sdk-libs/macros/src/utils.rs b/sdk-libs/macros/src/utils.rs index b84eb1e9f8..eccbaf9ff1 100644 --- a/sdk-libs/macros/src/utils.rs +++ b/sdk-libs/macros/src/utils.rs @@ -17,3 +17,32 @@ use syn::Result; pub(crate) fn into_token_stream(result: Result) -> TokenStream { result.unwrap_or_else(|err| err.to_compile_error()).into() } + +/// Convert snake_case to CamelCase (e.g., user_record -> UserRecord) +pub(crate) fn snake_to_camel_case(s: &str) -> String { + s.split('_') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} + +/// Convert PascalCase/CamelCase to snake_case (e.g., UserRecord -> user_record) +pub(crate) fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + result +}