From d15ae97b2858c33f3cb82e6ee9369bd6b76b4bdd Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 19 Jan 2026 20:36:44 +0000 Subject: [PATCH 1/4] feat: init token, ata macros --- .../macros/src/light_pdas/accounts/builder.rs | 79 ++- .../macros/src/light_pdas/accounts/derive.rs | 196 +++++- .../src/light_pdas/accounts/light_account.rs | 567 ++++++++++++++++-- .../macros/src/light_pdas/accounts/mint.rs | 1 + .../macros/src/light_pdas/accounts/mod.rs | 1 + .../macros/src/light_pdas/accounts/parse.rs | 17 +- .../macros/src/light_pdas/accounts/token.rs | 240 ++++++++ .../macros/src/light_pdas/program/parsing.rs | 104 +++- .../src/d10_token_accounts.rs | 3 + .../instructions/d10_token_accounts/mod.rs | 17 + .../d10_token_accounts/single_ata.rs | 52 ++ .../d10_token_accounts/single_vault.rs | 66 ++ .../src/instructions/mod.rs | 1 + .../csdk-anchor-full-derived-test/src/lib.rs | 37 ++ .../tests/d10_token_accounts_test.rs | 210 +++++++ 15 files changed, 1494 insertions(+), 97 deletions(-) create mode 100644 sdk-libs/macros/src/light_pdas/accounts/token.rs create mode 100644 sdk-tests/csdk-anchor-full-derived-test/src/d10_token_accounts.rs create mode 100644 sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs create mode 100644 sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs create mode 100644 sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_vault.rs create mode 100644 sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs diff --git a/sdk-libs/macros/src/light_pdas/accounts/builder.rs b/sdk-libs/macros/src/light_pdas/accounts/builder.rs index 8e864c84b2..3287e54311 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/builder.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/builder.rs @@ -11,6 +11,7 @@ use super::{ mint::{InfraRefs, LightMintsBuilder}, parse::{InfraFieldType, ParsedLightAccountsStruct}, pda::generate_pda_compress_blocks, + token::TokenAccountsBuilder, }; /// Builder for RentFree derive macro code generation. @@ -46,15 +47,20 @@ impl LightAccountsBuilder { /// Validate constraints (e.g., account count < 255). pub fn validate(&self) -> Result<(), syn::Error> { - let total = self.parsed.rentfree_fields.len() + self.parsed.light_mint_fields.len(); + let total = self.parsed.rentfree_fields.len() + + self.parsed.light_mint_fields.len() + + self.parsed.token_account_fields.len() + + self.parsed.ata_fields.len(); if total > 255 { return Err(syn::Error::new_spanned( &self.parsed.struct_name, format!( - "Too many compression fields ({} PDAs + {} mints = {} total, maximum 255). \ + "Too many compression fields ({} PDAs + {} mints + {} tokens + {} ATAs = {} total, maximum 255). \ Light Protocol uses u8 for account indices.", self.parsed.rentfree_fields.len(), self.parsed.light_mint_fields.len(), + self.parsed.token_account_fields.len(), + self.parsed.ata_fields.len(), total ), )); @@ -70,9 +76,11 @@ impl LightAccountsBuilder { fn validate_infra_fields(&self) -> Result<(), syn::Error> { let has_pdas = self.has_pdas(); let has_mints = self.has_mints(); + let has_token_accounts = self.has_token_accounts(); + let has_atas = self.has_atas(); // Skip validation if no light_account fields - if !has_pdas && !has_mints { + if !has_pdas && !has_mints && !has_token_accounts && !has_atas { return Ok(()); } @@ -88,27 +96,38 @@ impl LightAccountsBuilder { missing.push(InfraFieldType::CompressionConfig); } - // Mints require light_token_config, light_token_rent_sponsor, light_token_cpi_authority - if has_mints { + // Mints, token accounts, and ATAs require light_token infrastructure + let needs_token_infra = has_mints || has_token_accounts || has_atas; + if needs_token_infra { if self.parsed.infra_fields.light_token_config.is_none() { missing.push(InfraFieldType::LightTokenConfig); } if self.parsed.infra_fields.light_token_rent_sponsor.is_none() { missing.push(InfraFieldType::LightTokenRentSponsor); } - if self.parsed.infra_fields.light_token_cpi_authority.is_none() { + // CPI authority is required for mints and token accounts (PDA-based signing) + if (has_mints || has_token_accounts) + && self.parsed.infra_fields.light_token_cpi_authority.is_none() + { missing.push(InfraFieldType::LightTokenCpiAuthority); } } if !missing.is_empty() { - let context = if has_pdas && has_mints { - "PDA and mint" - } else if has_mints { - "mint" - } else { - "PDA" - }; + let mut types = Vec::new(); + if has_pdas { + types.push("PDA"); + } + if has_mints { + types.push("mint"); + } + if has_token_accounts { + types.push("token account"); + } + if has_atas { + types.push("ATA"); + } + let context = types.join(", "); let mut msg = format!( "#[derive(LightAccounts)] with {} fields requires the following infrastructure fields:\n", @@ -129,16 +148,26 @@ impl LightAccountsBuilder { Ok(()) } - /// Query: any #[light_account(init)] fields? + /// Query: any #[light_account(init)] PDA fields? pub fn has_pdas(&self) -> bool { !self.parsed.rentfree_fields.is_empty() } - /// Query: any #[light_account(init)] fields? + /// Query: any #[light_account(init, mint, ...)] fields? pub fn has_mints(&self) -> bool { !self.parsed.light_mint_fields.is_empty() } + /// Query: any #[light_account(init, token, ...)] fields? + pub fn has_token_accounts(&self) -> bool { + !self.parsed.token_account_fields.is_empty() + } + + /// Query: any #[light_account(init, ata, ...)] fields? + pub fn has_atas(&self) -> bool { + !self.parsed.ata_fields.is_empty() + } + /// Query: #[instruction(...)] present? pub fn has_instruction_args(&self) -> bool { self.parsed.instruction_args.is_some() @@ -374,4 +403,24 @@ impl LightAccountsBuilder { } }) } + + /// Check if token accounts or ATAs need finalize code generation. + pub fn needs_token_finalize(&self) -> bool { + TokenAccountsBuilder::new( + &self.parsed.token_account_fields, + &self.parsed.ata_fields, + &self.infra, + ) + .needs_finalize() + } + + /// Generate finalize body for token accounts and ATAs. + pub fn generate_token_finalize_body(&self) -> TokenStream { + TokenAccountsBuilder::new( + &self.parsed.token_account_fields, + &self.parsed.ata_fields, + &self.infra, + ) + .generate_finalize_body() + } } diff --git a/sdk-libs/macros/src/light_pdas/accounts/derive.rs b/sdk-libs/macros/src/light_pdas/accounts/derive.rs index 9d84cffc7b..50363fde90 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/derive.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/derive.rs @@ -46,10 +46,204 @@ pub(super) fn derive_light_accounts(input: &DeriveInput) -> Result { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, token, authority = [b"authority"], mint = my_mint, owner = fee_payer)] + pub vault: Account<'info, CToken>, + + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + pub light_token_cpi_authority: AccountInfo<'info>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "Token account derive should succeed"); + + let output = result.unwrap().to_string(); + + // Verify finalize generates token account creation + assert!( + output.contains("LightFinalize"), + "Should generate LightFinalize impl" + ); + assert!( + output.contains("CreateTokenAccountCpi"), + "Should generate CreateTokenAccountCpi call" + ); + assert!( + output.contains("rent_free"), + "Should call rent_free on CreateTokenAccountCpi" + ); + assert!( + output.contains("invoke_signed"), + "Should call invoke_signed with seeds" + ); + } + + #[test] + fn test_ata_with_init_generates_create_cpi() { + // ATA with init should generate create_associated_token_account_idempotent in finalize + let input: DeriveInput = parse_quote! { + #[instruction(params: CreateAtaParams)] + pub struct CreateAta<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, ata, owner = wallet, mint = my_mint)] + pub user_ata: Account<'info, CToken>, + + pub wallet: AccountInfo<'info>, + pub my_mint: AccountInfo<'info>, + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "ATA derive should succeed"); + + let output = result.unwrap().to_string(); + + // Verify finalize generates ATA creation + assert!( + output.contains("LightFinalize"), + "Should generate LightFinalize impl" + ); + assert!( + output.contains("CreateTokenAtaCpi"), + "Should generate CreateTokenAtaCpi call" + ); + } + + #[test] + fn test_token_mark_only_generates_noop_finalize() { + // Token without init should NOT generate creation code (mark-only mode) + // Mark-only returns None from parsing, so token_account_fields is empty + let input: DeriveInput = parse_quote! { + #[instruction(params: UseVaultParams)] + pub struct UseVault<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + // Mark-only: no init keyword + #[light_account(token, authority = [b"authority"])] + pub vault: Account<'info, CToken>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "Mark-only token derive should succeed"); + + let output = result.unwrap().to_string(); + + // Mark-only should NOT have token account creation + assert!( + !output.contains("CreateTokenAccountCpi"), + "Mark-only should NOT generate CreateTokenAccountCpi" + ); + + // Should still have LightFinalize but with Ok(()) + assert!( + output.contains("LightFinalize"), + "Should still generate LightFinalize impl" + ); + } + + #[test] + fn test_mixed_token_and_ata_generates_both() { + // Mixed token account + ATA should generate both creation codes + let input: DeriveInput = parse_quote! { + #[instruction(params: CreateBothParams)] + pub struct CreateBoth<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, token, authority = [b"authority"], mint = my_mint, owner = fee_payer)] + pub vault: Account<'info, CToken>, + + #[light_account(init, ata, owner = wallet, mint = my_mint)] + pub user_ata: Account<'info, CToken>, + + pub wallet: AccountInfo<'info>, + pub my_mint: AccountInfo<'info>, + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + pub light_token_cpi_authority: AccountInfo<'info>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "Mixed token+ATA derive should succeed"); + + let output = result.unwrap().to_string(); + + // Should have both creation types + assert!( + output.contains("CreateTokenAccountCpi"), + "Should generate CreateTokenAccountCpi for vault" + ); + assert!( + output.contains("CreateTokenAtaCpi"), + "Should generate CreateTokenAtaCpi for ATA" + ); + } + + #[test] + fn test_no_instruction_args_generates_noop() { + // No #[instruction] attribute should generate no-op impls + let input: DeriveInput = parse_quote! { + pub struct NoInstruction<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "No instruction args should succeed"); + + let output = result.unwrap().to_string(); + + // Should generate no-op impls with () param type + assert!( + output.contains("LightPreInit"), + "Should generate LightPreInit impl" + ); + assert!( + output.contains("LightFinalize"), + "Should generate LightFinalize impl" + ); + // No-op returns Ok(false) in pre_init and Ok(()) in finalize + assert!( + output.contains("Ok (false)") || output.contains("Ok(false)"), + "Should return Ok(false) in pre_init" + ); + } +} diff --git a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs index f046a62b43..a3e573aab3 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs @@ -26,9 +26,8 @@ pub enum LightAccountType { #[default] Pda, // Default (no type specifier) - for PDAs Mint, // `mint` keyword - for compressed mints - // Future: - // TokenAccount, // `token_account` keyword - // Ata, // `ata` keyword + Token, // `token` keyword - for token accounts + Ata, // `ata` keyword - for ATAs } // ============================================================================ @@ -36,12 +35,16 @@ pub enum LightAccountType { // ============================================================================ /// Unified representation of a #[light_account(...)] field. +#[derive(Debug)] pub enum LightAccountField { Pda(Box), Mint(Box), + TokenAccount(Box), + Ata(Box), } /// A field marked with #[light_account(init)] (PDA). +#[derive(Debug)] pub struct PdaField { pub ident: Ident, /// The inner type T from Account<'info, T> or Box> @@ -52,6 +55,40 @@ pub struct PdaField { pub is_boxed: bool, } +/// A field marked with #[light_account([init,] token, ...)] (Token Account). +#[derive(Clone, Debug)] +pub struct TokenAccountField { + pub field_ident: Ident, + /// True if `init` keyword is present (generate creation code) + pub has_init: bool, + /// Seeds for the token account PDA (from #[account(seeds = [...], bump)]) + pub seeds: Vec, + /// Authority seeds for the PDA owner (from authority = [...] parameter) + pub authority_seeds: Vec, + /// Mint reference (extracted from seeds or explicit parameter) + pub mint: Option, + /// Owner reference (the PDA that owns this token account) + pub owner: Option, + /// Compression-only flag (default: true when has_init = true) + pub compression_only: bool, +} + +/// A field marked with #[light_account([init,] ata, ...)] (Associated Token Account). +#[derive(Clone, Debug)] +pub struct AtaField { + pub field_ident: Ident, + /// True if `init` keyword is present (generate creation code) + pub has_init: bool, + /// Owner of the ATA (from owner = ... parameter) + pub owner: Expr, + /// Mint for the ATA (from mint = ... parameter) + pub mint: Expr, + /// Bump seed (from #[account(seeds = [...], bump)]) + pub bump: Option, + /// Compression-only flag (default: true when has_init = true) + pub compression_only: bool, +} + // ============================================================================ // Custom Parser for #[light_account(init, [mint,] key = value, ...)] // ============================================================================ @@ -85,53 +122,29 @@ struct LightAccountArgs { impl Parse for LightAccountArgs { fn parse(input: ParseStream) -> syn::Result { - // First token must be `init` or `token` + // First token must be `init`, `token`, or `ata` let first: Ident = input.parse()?; - // If first argument is `token`, this is a token field handled by light_program - // Validate remaining tokens - only `authority = [...]` is allowed - if first == "token" { - while !input.is_empty() { - input.parse::()?; - - if input.is_empty() { - break; - } - - let key: Ident = input.parse()?; - if key != "authority" { - return Err(Error::new_spanned( - &key, - format!( - "Unknown argument `{}` in #[light_account(token, ...)]. \ - Only `authority` is allowed.", - key - ), - )); - } - - input.parse::()?; - - // Parse the bracketed content for authority seeds - let content; - syn::bracketed!(content in input); - // Consume the bracket contents (validated by seed_extraction.rs) - while !content.is_empty() { - let _: proc_macro2::TokenTree = content.parse()?; - } - } + // Handle `token` or `ata` as first argument (mark-only mode, no init) + if first == "token" || first == "ata" { + let account_type = if first == "token" { + LightAccountType::Token + } else { + LightAccountType::Ata + }; + let key_values = parse_token_ata_key_values(input, &first)?; return Ok(Self { has_init: false, - is_token: true, - account_type: LightAccountType::Pda, // not used for token - key_values: Vec::new(), + is_token: true, // Skip in LightAccounts derive (for mark-only mode) + account_type, + key_values, }); } if first != "init" { return Err(Error::new_spanned( &first, - "First argument to #[light_account] must be `init` or `token`", + "First argument to #[light_account] must be `init`, `token`, or `ata`", )); } @@ -142,18 +155,31 @@ impl Parse for LightAccountArgs { while !input.is_empty() { input.parse::()?; - // Check if this is a type keyword (mint, token_account, ata) + // Check if this is a type keyword (mint, token, ata) if input.peek(Ident) { let lookahead = input.fork(); let ident: Ident = lookahead.parse()?; - // Check for type keywords - if ident == "mint" && !lookahead.peek(Token![=]) { - input.parse::()?; // consume it - account_type = LightAccountType::Mint; - continue; + // Check for type keywords (not followed by `=`) + if !lookahead.peek(Token![=]) { + if ident == "mint" { + input.parse::()?; // consume it + account_type = LightAccountType::Mint; + continue; + } else if ident == "token" { + input.parse::()?; // consume it + account_type = LightAccountType::Token; + // Parse remaining token-specific key-values + key_values = parse_token_ata_key_values(input, &ident)?; + break; + } else if ident == "ata" { + input.parse::()?; // consume it + account_type = LightAccountType::Ata; + // Parse remaining ata-specific key-values + key_values = parse_token_ata_key_values(input, &ident)?; + break; + } } - // Future: token_account, ata keywords } // Otherwise it's a key-value pair @@ -172,13 +198,76 @@ impl Parse for LightAccountArgs { } } +/// Parse key-value pairs for token and ata attributes. +/// Handles both bracketed arrays (authority = [...]) and simple values (owner = ident). +fn parse_token_ata_key_values( + input: ParseStream, + account_type_name: &Ident, +) -> syn::Result> { + let mut key_values = Vec::new(); + let valid_keys = if account_type_name == "token" { + &["authority", "mint", "owner", "compression_only"][..] + } else { + // ata + &["owner", "mint", "bump", "compression_only"][..] + }; + + while !input.is_empty() { + input.parse::()?; + + if input.is_empty() { + break; + } + + let key: Ident = input.parse()?; + let key_str = key.to_string(); + + if !valid_keys.contains(&key_str.as_str()) { + return Err(Error::new_spanned( + &key, + format!( + "Unknown argument `{}` in #[light_account({}, ...)]. \ + Allowed: {}", + key, + account_type_name, + valid_keys.join(", ") + ), + )); + } + + input.parse::()?; + + // Handle bracketed content for authority seeds + let value: Expr = if key == "authority" && input.peek(syn::token::Bracket) { + let content; + syn::bracketed!(content in input); + // Parse as array expression + let mut elements = Vec::new(); + while !content.is_empty() { + let elem: Expr = content.parse()?; + elements.push(elem); + if content.peek(Token![,]) { + content.parse::()?; + } + } + syn::parse_quote!([#(#elements),*]) + } else { + input.parse()? + }; + + key_values.push(KeyValue { key, value }); + } + + Ok(key_values) +} + // ============================================================================ // Main Parsing Function // ============================================================================ /// Parse #[light_account(...)] attribute from a field. -/// Returns None if no light_account attribute or if it's a token field (handled elsewhere). -/// Returns Some(LightAccountField) for PDA or Mint fields. +/// Returns None if no light_account attribute or if it's a mark-only token/ata field. +/// Returns Some(LightAccountField) for PDA, Mint, or init Token/Ata fields. pub(super) fn parse_light_account_attr( field: &Field, field_ident: &Ident, @@ -187,16 +276,20 @@ pub(super) fn parse_light_account_attr( if attr.path().is_ident("light_account") { let args: LightAccountArgs = attr.parse_args()?; - // Token fields are handled by light_program macro (seed_extraction.rs) + // Mark-only mode (token/ata without init) - handled by light_program macro // Return None so LightAccounts derive skips them - if args.is_token { + if args.is_token && !args.has_init { return Ok(None); } - if !args.has_init { + // For PDA and Mint, init is required + if !args.has_init + && (args.account_type == LightAccountType::Pda + || args.account_type == LightAccountType::Mint) + { return Err(Error::new_spanned( attr, - "#[light_account] requires `init` as the first argument (or use `token` for token accounts)", + "#[light_account] requires `init` as the first argument for PDA/Mint", )); } @@ -207,6 +300,12 @@ pub(super) fn parse_light_account_attr( LightAccountType::Mint => Ok(Some(LightAccountField::Mint(Box::new( build_mint_field(field_ident, &args.key_values, attr)?, )))), + LightAccountType::Token => Ok(Some(LightAccountField::TokenAccount(Box::new( + build_token_account_field(field_ident, &args.key_values, args.has_init, attr)?, + )))), + LightAccountType::Ata => Ok(Some(LightAccountField::Ata(Box::new( + build_ata_field(field_ident, &args.key_values, args.has_init, attr)?, + )))), }; } } @@ -375,6 +474,128 @@ fn build_mint_field( }) } +/// Build a TokenAccountField from parsed key-value pairs. +fn build_token_account_field( + field_ident: &Ident, + key_values: &[KeyValue], + has_init: bool, + attr: &syn::Attribute, +) -> Result { + let mut authority: Option = None; + let mut mint: Option = None; + let mut owner: Option = None; + let mut compression_only: Option = None; + + for kv in key_values { + match kv.key.to_string().as_str() { + "authority" => authority = Some(kv.value.clone()), + "mint" => mint = Some(kv.value.clone()), + "owner" => owner = Some(kv.value.clone()), + "compression_only" => { + compression_only = Some(expr_to_bool(&kv.value, "compression_only")?); + } + other => { + return Err(Error::new_spanned( + &kv.key, + format!( + "Unknown argument `{other}` for token. \ + Expected: authority, mint, owner, compression_only" + ), + )); + } + } + } + + // Validate required fields for init mode + if has_init { + if authority.is_none() { + return Err(Error::new_spanned( + attr, + "#[light_account(init, token, ...)] requires `authority = [...]` parameter", + )); + } + } + + // Extract authority seeds from the array expression + let authority_seeds = if let Some(ref auth_expr) = authority { + extract_array_elements(auth_expr)? + } else { + Vec::new() + }; + + // Default compression_only to true when init is present + let compression_only = compression_only.unwrap_or(has_init); + + Ok(TokenAccountField { + field_ident: field_ident.clone(), + has_init, + seeds: Vec::new(), // Seeds will be extracted from #[account(seeds = [...])] later + authority_seeds, + mint, + owner, + compression_only, + }) +} + +/// Build an AtaField from parsed key-value pairs. +fn build_ata_field( + field_ident: &Ident, + key_values: &[KeyValue], + has_init: bool, + attr: &syn::Attribute, +) -> Result { + let mut owner: Option = None; + let mut mint: Option = None; + let mut bump: Option = None; + let mut compression_only: Option = None; + + for kv in key_values { + match kv.key.to_string().as_str() { + "owner" => owner = Some(kv.value.clone()), + "mint" => mint = Some(kv.value.clone()), + "bump" => bump = Some(kv.value.clone()), + "compression_only" => { + compression_only = Some(expr_to_bool(&kv.value, "compression_only")?); + } + other => { + return Err(Error::new_spanned( + &kv.key, + format!( + "Unknown argument `{other}` in #[light_account(ata, ...)]. \ + Allowed: owner, mint, bump, compression_only" + ), + )); + } + } + } + + // Validate required fields + let owner = owner.ok_or_else(|| { + Error::new_spanned( + attr, + "#[light_account([init,] ata, ...)] requires `owner` parameter", + ) + })?; + let mint = mint.ok_or_else(|| { + Error::new_spanned( + attr, + "#[light_account([init,] ata, ...)] requires `mint` parameter", + ) + })?; + + // Default compression_only to true when init is present + let compression_only = compression_only.unwrap_or(has_init); + + Ok(AtaField { + field_ident: field_ident.clone(), + has_init, + owner, + mint, + bump, + compression_only, + }) +} + /// Convert an expression to an identifier (for field references). fn expr_to_ident(expr: &Expr, field_name: &str) -> Result { match expr { @@ -388,6 +609,51 @@ fn expr_to_ident(expr: &Expr, field_name: &str) -> Result { } } +/// Convert an expression to a boolean (for flags like compression_only). +fn expr_to_bool(expr: &Expr, field_name: &str) -> Result { + match expr { + Expr::Lit(lit) => { + if let syn::Lit::Bool(b) = &lit.lit { + Ok(b.value) + } else { + Err(Error::new_spanned( + expr, + format!("`{field_name}` must be `true` or `false`"), + )) + } + } + Expr::Path(path) => { + let ident = path.path.get_ident().ok_or_else(|| { + Error::new_spanned(expr, format!("`{field_name}` must be `true` or `false`")) + })?; + match ident.to_string().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(Error::new_spanned( + expr, + format!("`{field_name}` must be `true` or `false`"), + )), + } + } + _ => Err(Error::new_spanned( + expr, + format!("`{field_name}` must be `true` or `false`"), + )), + } +} + +/// Extract elements from an array expression. +fn extract_array_elements(expr: &Expr) -> Result, syn::Error> { + match expr { + Expr::Array(arr) => Ok(arr.elems.iter().cloned().collect()), + Expr::Reference(r) => extract_array_elements(&r.expr), + _ => Err(Error::new_spanned( + expr, + "Expected array expression like `[b\"seed\", other.key()]`", + )), + } +} + /// Validates TokenMetadata field requirements. /// /// Rules: @@ -605,4 +871,199 @@ mod tests { assert!(result.is_ok()); assert!(result.unwrap().is_none()); } + + // ======================================================================== + // Token Account Tests + // ======================================================================== + + #[test] + fn test_parse_token_mark_only_returns_none() { + // Mark-only mode (no init) should return None for LightAccounts derive + let field: syn::Field = parse_quote! { + #[light_account(token, authority = [b"authority"])] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_parse_token_init_creates_field() { + let field: syn::Field = parse_quote! { + #[light_account(init, token, authority = [b"authority"])] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::TokenAccount(token) => { + assert_eq!(token.field_ident.to_string(), "vault"); + assert!(token.has_init); + assert!(token.compression_only); // default true when has_init + assert!(!token.authority_seeds.is_empty()); + } + _ => panic!("Expected TokenAccount field"), + } + } + + #[test] + fn test_parse_token_init_with_compression_only_false() { + let field: syn::Field = parse_quote! { + #[light_account(init, token, authority = [b"authority"], compression_only = false)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::TokenAccount(token) => { + assert!(!token.compression_only); + } + _ => panic!("Expected TokenAccount field"), + } + } + + #[test] + fn test_parse_token_init_missing_authority_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, token)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("authority")); + } + + // ======================================================================== + // ATA Tests + // ======================================================================== + + #[test] + fn test_parse_ata_mark_only_returns_none() { + // Mark-only mode (no init) should return None for LightAccounts derive + let field: syn::Field = parse_quote! { + #[light_account(ata, owner = owner, mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_parse_ata_init_creates_field() { + let field: syn::Field = parse_quote! { + #[light_account(init, ata, owner = owner, mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Ata(ata) => { + assert_eq!(ata.field_ident.to_string(), "user_ata"); + assert!(ata.has_init); + assert!(ata.compression_only); // default true when has_init + } + _ => panic!("Expected Ata field"), + } + } + + #[test] + fn test_parse_ata_init_with_compression_only_false() { + let field: syn::Field = parse_quote! { + #[light_account(init, ata, owner = owner, mint = mint, compression_only = false)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Ata(ata) => { + assert!(!ata.compression_only); + } + _ => panic!("Expected Ata field"), + } + } + + #[test] + fn test_parse_ata_init_missing_owner_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, ata, mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("owner")); + } + + #[test] + fn test_parse_ata_init_missing_mint_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, ata, owner = owner)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("mint")); + } + + #[test] + fn test_parse_token_unknown_argument_fails() { + let field: syn::Field = parse_quote! { + #[light_account(token, authority = [b"auth"], unknown = foo)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("unknown")); + } + + #[test] + fn test_parse_ata_unknown_argument_fails() { + let field: syn::Field = parse_quote! { + #[light_account(ata, owner = owner, mint = mint, unknown = foo)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("unknown")); + } } diff --git a/sdk-libs/macros/src/light_pdas/accounts/mint.rs b/sdk-libs/macros/src/light_pdas/accounts/mint.rs index 3ea3ba6f29..ec44c0f974 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/mint.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/mint.rs @@ -22,6 +22,7 @@ use super::parse::InfraFields; // ============================================================================ /// A field marked with #[light_account(init, mint, ...)] +#[derive(Debug)] pub(super) struct LightMintField { /// The field name where #[light_account(init, mint, ...)] is attached (Mint account) pub field_ident: Ident, diff --git a/sdk-libs/macros/src/light_pdas/accounts/mod.rs b/sdk-libs/macros/src/light_pdas/accounts/mod.rs index e4f81b2991..53931ae443 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/mod.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/mod.rs @@ -18,6 +18,7 @@ mod light_account; mod mint; mod parse; mod pda; +mod token; use proc_macro2::TokenStream; use syn::DeriveInput; diff --git a/sdk-libs/macros/src/light_pdas/accounts/parse.rs b/sdk-libs/macros/src/light_pdas/accounts/parse.rs index 707924b3af..d77cc58603 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/parse.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/parse.rs @@ -10,7 +10,7 @@ use syn::{ }; // Import unified parsing from light_account module -use super::light_account::{parse_light_account_attr, LightAccountField}; +use super::light_account::{parse_light_account_attr, AtaField, LightAccountField, TokenAccountField}; // Import LightMintField from mint module (for type export) pub(super) use super::mint::LightMintField; @@ -157,6 +157,8 @@ pub(super) struct ParsedLightAccountsStruct { pub generics: syn::Generics, pub rentfree_fields: Vec, pub light_mint_fields: Vec, + pub token_account_fields: Vec, + pub ata_fields: Vec, pub instruction_args: Option>, /// Infrastructure fields detected by naming convention. pub infra_fields: InfraFields, @@ -226,6 +228,8 @@ pub(super) fn parse_light_accounts_struct( let mut rentfree_fields = Vec::new(); let mut light_mint_fields = Vec::new(); + let mut token_account_fields = Vec::new(); + let mut ata_fields = Vec::new(); let mut infra_fields = InfraFields::default(); for field in fields { @@ -246,14 +250,19 @@ pub(super) fn parse_light_accounts_struct( match light_account_field { LightAccountField::Pda(pda) => rentfree_fields.push((*pda).into()), LightAccountField::Mint(mint) => light_mint_fields.push(*mint), + LightAccountField::TokenAccount(token) => token_account_fields.push(*token), + LightAccountField::Ata(ata) => ata_fields.push(*ata), } continue; // Field processed, move to next } } // Validation: #[light_account] fields require #[instruction] attribute - if (!rentfree_fields.is_empty() || !light_mint_fields.is_empty()) && instruction_args.is_none() - { + let has_light_account_fields = !rentfree_fields.is_empty() + || !light_mint_fields.is_empty() + || !token_account_fields.is_empty() + || !ata_fields.is_empty(); + if has_light_account_fields && instruction_args.is_none() { return Err(Error::new_spanned( input, "#[light_account] fields require #[instruction(params: YourParamsType)] \ @@ -266,6 +275,8 @@ pub(super) fn parse_light_accounts_struct( generics, rentfree_fields, light_mint_fields, + token_account_fields, + ata_fields, instruction_args, infra_fields, }) diff --git a/sdk-libs/macros/src/light_pdas/accounts/token.rs b/sdk-libs/macros/src/light_pdas/accounts/token.rs new file mode 100644 index 0000000000..df48165072 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas/accounts/token.rs @@ -0,0 +1,240 @@ +//! Light token account code generation. +//! +//! This module handles code generation for token account and ATA CPI invocations. +//! Parsing is handled by `light_account.rs`. +//! +//! ## Code Generation +//! +//! Token accounts and ATAs are created in `LightFinalize` (after instruction logic) +//! because they may depend on data set during the instruction handler. +//! +//! - **Token Accounts**: Use `CreateTokenAccountCpi` with PDA signing +//! - **ATAs**: Use `CreateTokenAtaCpi` with `idempotent()` builder +//! +//! ## Requirements +//! +//! Programs using `#[light_account(init, token, ...)]` must have a `crate::ID` +//! constant, which is the standard pattern when using Anchor's `declare_id!` macro. +//! The generated code passes `&crate::ID` to `CreateTokenAccountCpi::rent_free()` +//! for PDA signing verification. + +use proc_macro2::TokenStream; +use quote::quote; + +use super::light_account::{AtaField, TokenAccountField}; +use super::mint::InfraRefs; + +/// Generate token account creation CPI code for a single token account field. +/// +/// Generated code uses `CreateTokenAccountCpi` with rent-free mode and PDA signing. +#[allow(dead_code)] +pub(super) fn generate_token_account_cpi( + field: &TokenAccountField, + infra: &InfraRefs, +) -> Option { + // Only generate creation code if has_init is true + if !field.has_init { + return None; + } + + let field_ident = &field.field_ident; + let light_token_config = &infra.light_token_config; + let light_token_rent_sponsor = &infra.light_token_rent_sponsor; + let fee_payer = &infra.fee_payer; + + // Generate authority seeds array from parsed seeds + // Bind each seed to a local variable first, then call .as_ref() to avoid + // temporary lifetime issues (e.g., self.mint.key() creates a Pubkey that + // would be dropped before .as_ref() completes if done in one expression) + // + // User provides expressions WITHOUT .as_ref() - code generation adds it: + // authority = [SEED, self.mint.key(), &[bump]] + // Generates: + // let __seed_0 = SEED; let __seed_0_ref: &[u8] = __seed_0.as_ref(); + // let __seed_1 = self.mint.key(); let __seed_1_ref: &[u8] = __seed_1.as_ref(); + // let __seed_2 = &[bump]; let __seed_2_ref: &[u8] = __seed_2.as_ref(); + // &[__seed_0_ref, __seed_1_ref, __seed_2_ref] + let authority_seeds = &field.authority_seeds; + let seed_bindings: Vec = authority_seeds + .iter() + .enumerate() + .map(|(i, seed)| { + let val_name = syn::Ident::new(&format!("__seed_{}", i), proc_macro2::Span::call_site()); + let ref_name = syn::Ident::new(&format!("__seed_{}_ref", i), proc_macro2::Span::call_site()); + quote! { + let #val_name = #seed; + let #ref_name: &[u8] = #val_name.as_ref(); + } + }) + .collect(); + let seed_refs: Vec = (0..authority_seeds.len()) + .map(|i| { + let ref_name = syn::Ident::new(&format!("__seed_{}_ref", i), proc_macro2::Span::call_site()); + quote! { #ref_name } + }) + .collect(); + let seeds_array_expr = if authority_seeds.is_empty() { + quote! { &[] } + } else { + quote! { &[#(#seed_refs),*] } + }; + + // Get mint and owner from field or derive from context + // mint is used as AccountInfo for CPI + let mint_account_info = field + .mint + .as_ref() + .map(|m| quote! { self.#m.to_account_info() }) + .unwrap_or_else(|| quote! { self.mint.to_account_info() }); + + // owner is a Pubkey - the owner of the token account + let owner_expr = field + .owner + .as_ref() + .map(|o| quote! { *self.#o.to_account_info().key }) + .unwrap_or_else(|| quote! { *self.fee_payer.to_account_info().key }); + + Some(quote! { + // Create token account: #field_ident + { + use light_token_sdk::token::CreateTokenAccountCpi; + + // Bind seeds to local variables to extend temporary lifetimes + #(#seed_bindings)* + let __token_account_seeds: &[&[u8]] = #seeds_array_expr; + + CreateTokenAccountCpi { + payer: self.#fee_payer.to_account_info(), + account: self.#field_ident.to_account_info(), + mint: #mint_account_info, + owner: #owner_expr, + } + .rent_free( + self.#light_token_config.to_account_info(), + self.#light_token_rent_sponsor.to_account_info(), + __system_program.clone(), + &crate::ID, + ) + .invoke_signed(__token_account_seeds)?; + } + }) +} + +/// Generate ATA creation CPI code for a single ATA field. +/// +/// Generated code uses `CreateTokenAtaCpi` builder with rent-free mode. +#[allow(dead_code)] +pub(super) fn generate_ata_cpi(field: &AtaField, infra: &InfraRefs) -> Option { + // Only generate creation code if has_init is true + if !field.has_init { + return None; + } + + let field_ident = &field.field_ident; + let owner = &field.owner; + let mint = &field.mint; + let light_token_config = &infra.light_token_config; + let light_token_rent_sponsor = &infra.light_token_rent_sponsor; + let fee_payer = &infra.fee_payer; + + // Get bump from field or use derived bump + let bump_expr = field + .bump + .as_ref() + .map(|b| quote! { #b }) + .unwrap_or_else(|| { + quote! { + { + let (_, bump) = light_token_sdk::token::derive_token_ata( + self.#owner.to_account_info().key, + self.#mint.to_account_info().key, + ); + bump + } + } + }); + + Some(quote! { + // Create ATA: #field_ident + { + use light_token_sdk::token::CreateTokenAtaCpi; + + CreateTokenAtaCpi { + payer: self.#fee_payer.to_account_info(), + owner: self.#owner.to_account_info(), + mint: self.#mint.to_account_info(), + ata: self.#field_ident.to_account_info(), + bump: #bump_expr, + } + .idempotent() + .rent_free( + self.#light_token_config.to_account_info(), + self.#light_token_rent_sponsor.to_account_info(), + __system_program.clone(), + ) + .invoke()?; + } + }) +} + +/// Builder for generating finalize code for token accounts and ATAs. +pub(super) struct TokenAccountsBuilder<'a> { + token_account_fields: &'a [TokenAccountField], + ata_fields: &'a [AtaField], + infra: &'a InfraRefs, +} + +impl<'a> TokenAccountsBuilder<'a> { + /// Create a new builder. + pub fn new( + token_account_fields: &'a [TokenAccountField], + ata_fields: &'a [AtaField], + infra: &'a InfraRefs, + ) -> Self { + Self { + token_account_fields, + ata_fields, + infra, + } + } + + /// Check if any token accounts or ATAs need to be created. + pub fn needs_finalize(&self) -> bool { + self.token_account_fields.iter().any(|f| f.has_init) + || self.ata_fields.iter().any(|f| f.has_init) + } + + /// Generate the finalize body for token accounts and ATAs. + pub fn generate_finalize_body(&self) -> TokenStream { + if !self.needs_finalize() { + return quote! { Ok(()) }; + } + + // Generate token account creation code + let token_account_cpis: Vec = self + .token_account_fields + .iter() + .filter_map(|f| generate_token_account_cpi(f, self.infra)) + .collect(); + + // Generate ATA creation code + let ata_cpis: Vec = self + .ata_fields + .iter() + .filter_map(|f| generate_ata_cpi(f, self.infra)) + .collect(); + + quote! { + // Get system program from the struct's system_program field + let __system_program = self.system_program.to_account_info(); + + // Create token accounts + #(#token_account_cpis)* + + // Create ATAs + #(#ata_cpis)* + + Ok(()) + } + } +} diff --git a/sdk-libs/macros/src/light_pdas/program/parsing.rs b/sdk-libs/macros/src/light_pdas/program/parsing.rs index 1de06b5755..a1c561181f 100644 --- a/sdk-libs/macros/src/light_pdas/program/parsing.rs +++ b/sdk-libs/macros/src/light_pdas/program/parsing.rs @@ -398,6 +398,39 @@ pub fn extract_context_and_params(fn_item: &ItemFn) -> Option<(String, Ident)> { } } +/// Check if a function body is a simple delegation (single expression that moves ctx). +/// Returns true for patterns like `crate::module::function(ctx, params)`. +/// Does NOT match simple returns like `Ok(())` since those don't consume ctx. +fn is_delegation_body(block: &syn::Block) -> bool { + // Check if block has exactly one statement that's an expression + if block.stmts.len() != 1 { + return false; + } + match &block.stmts[0] { + syn::Stmt::Expr(expr, _) => { + // Check if it's a function call that takes ctx as an argument + match expr { + syn::Expr::Call(call) => call_has_ctx_arg(&call.args), + syn::Expr::MethodCall(call) => call_has_ctx_arg(&call.args), + _ => false, + } + } + _ => false, + } +} + +/// Check if any argument in the call is `ctx` (moving the context). +fn call_has_ctx_arg(args: &syn::punctuated::Punctuated) -> bool { + for arg in args { + if let syn::Expr::Path(path) = arg { + if path.path.is_ident("ctx") { + return true; + } + } + } + false +} + /// Wrap a function with pre_init/finalize logic. pub fn wrap_function_with_light(fn_item: &ItemFn, params_ident: &Ident) -> ItemFn { let fn_vis = &fn_item.vis; @@ -405,30 +438,51 @@ pub fn wrap_function_with_light(fn_item: &ItemFn, params_ident: &Ident) -> ItemF 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::interface::{LightPreInit, LightFinalize}; - let _ = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) - .map_err(|e: light_sdk::error::LightSdkError| -> solana_program_error::ProgramError { - e.into() - })?; - - // Execute the original handler body - #fn_block - - // TODO(diff-pr): Reactivate light_finalize for top up transfers. - // Currently disabled because user code may move ctx, making it - // inaccessible after the handler body executes. When top up - // transfers are implemented, we'll need to store AccountInfo - // references before user code runs. - // - // if __light_handler_result.is_ok() { - // ctx.accounts.light_finalize(ctx.remaining_accounts, ¶ms, __has_pre_init)?; - // } + // Check if this handler delegates to another function (which moves ctx) + // In that case, skip finalize since the delegated function handles everything + let is_delegation = is_delegation_body(fn_block); + + if is_delegation { + // For delegation handlers, just add pre_init before the delegation call + 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::interface::{LightPreInit, LightFinalize}; + let _ = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) + .map_err(|e: light_sdk::error::LightSdkError| -> solana_program_error::ProgramError { + e.into() + })?; + + // Execute delegation - this handles its own logic including any finalize + #fn_block + } } - }; - - wrapped + } else { + // For non-delegation handlers, add both pre_init and finalize + 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::interface::{LightPreInit, LightFinalize}; + let __has_pre_init = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) + .map_err(|e: light_sdk::error::LightSdkError| -> solana_program_error::ProgramError { + e.into() + })?; + + // Execute the original handler body and capture result + let __user_result: anchor_lang::Result<()> = #fn_block; + // Propagate any errors from user code + __user_result?; + + // Phase 2: Finalize (creates token accounts/ATAs via CPI) + ctx.accounts.light_finalize(ctx.remaining_accounts, &#params_ident, __has_pre_init) + .map_err(|e: light_sdk::error::LightSdkError| -> solana_program_error::ProgramError { + e.into() + })?; + + Ok(()) + } + } + } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/d10_token_accounts.rs b/sdk-tests/csdk-anchor-full-derived-test/src/d10_token_accounts.rs new file mode 100644 index 0000000000..6b43616777 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/d10_token_accounts.rs @@ -0,0 +1,3 @@ +//! Re-export d10_token_accounts from instructions module for top-level access. + +pub use crate::instructions::d10_token_accounts::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs new file mode 100644 index 0000000000..79544386c0 --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs @@ -0,0 +1,17 @@ +//! D10 Test: Token Account and ATA creation via macro +//! +//! Tests #[light_account(init, token, ...)] and #[light_account(init, ata, ...)] +//! macro code generation for creating compressed token accounts. +//! +//! These tests verify: +//! - Single vault creation with seeds (token account) +//! - Single ATA creation (associated token account) +//! - Multiple vaults in same instruction +//! - Token accounts with PDAs +//! - Token accounts with mints + +pub mod single_ata; +pub mod single_vault; + +pub use single_ata::*; +pub use single_vault::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs new file mode 100644 index 0000000000..68820f2a1a --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs @@ -0,0 +1,52 @@ +//! D10 Test: Single ATA creation via macro +//! +//! Tests #[light_account(init, ata, ...)] automatic code generation +//! for creating a single compressed token associated token account. +//! +//! This differs from D5 tests which use mark-only mode and manual creation. +//! Here the macro should generate CreateTokenAtaCpi call automatically. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::LightAccounts; +use light_sdk_types::LIGHT_TOKEN_PROGRAM_ID; +use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as LIGHT_TOKEN_RENT_SPONSOR}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D10SingleAtaParams { + pub create_accounts_proof: CreateAccountsProof, + /// Bump for the ATA PDA + pub ata_bump: u8, +} + +/// Tests #[light_account(init, ata, ...)] automatic code generation. +/// The macro should generate CreateTokenAtaCpi in LightFinalize. +#[derive(Accounts, LightAccounts)] +#[instruction(params: D10SingleAtaParams)] +pub struct D10SingleAta<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Token mint for the ATA + pub d10_ata_mint: AccountInfo<'info>, + + /// CHECK: Owner of the ATA (the fee_payer in this case) + pub d10_ata_owner: AccountInfo<'info>, + + /// ATA account - macro should generate creation code. + #[account(mut)] + #[light_account(init, ata, owner = d10_ata_owner, mint = d10_ata_mint, bump = params.ata_bump)] + pub d10_single_ata: UncheckedAccount<'info>, + + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub light_token_compressible_config: AccountInfo<'info>, + + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light Token Program for CPI + #[account(address = LIGHT_TOKEN_PROGRAM_ID.into())] + pub light_token_program: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_vault.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_vault.rs new file mode 100644 index 0000000000..dce017f0cd --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_vault.rs @@ -0,0 +1,66 @@ +//! D10 Test: Single token account creation via macro +//! +//! Tests #[light_account(init, token, ...)] automatic code generation +//! for creating a single compressed token account (CToken vault). +//! +//! This differs from D5 tests which use mark-only mode and manual creation. +//! Here the macro should generate the CreateTokenAccountCpi call automatically. + +use anchor_lang::prelude::*; +use light_compressible::CreateAccountsProof; +use light_sdk_macros::LightAccounts; +use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR as LIGHT_TOKEN_RENT_SPONSOR}; + +/// Seed for the vault authority PDA +pub const D10_SINGLE_VAULT_AUTH_SEED: &[u8] = b"d10_single_vault_auth"; +/// Seed for the vault token account PDA +pub const D10_SINGLE_VAULT_SEED: &[u8] = b"d10_single_vault"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D10SingleVaultParams { + pub create_accounts_proof: CreateAccountsProof, + /// Bump for the d10_single_vault PDA (needed for invoke_signed) + pub vault_bump: u8, +} + +/// Tests #[light_account(init, token, ...)] automatic code generation. +/// The macro should generate CreateTokenAccountCpi in LightFinalize. +#[derive(Accounts, LightAccounts)] +#[instruction(params: D10SingleVaultParams)] +pub struct D10SingleVault<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Token mint + pub d10_mint: AccountInfo<'info>, + + #[account( + seeds = [D10_SINGLE_VAULT_AUTH_SEED], + bump, + )] + pub d10_vault_authority: UncheckedAccount<'info>, + + /// Token vault account - macro should generate creation code. + /// The `authority` seeds must match the account's PDA seeds (including bump) for invoke_signed. + #[account( + mut, + seeds = [D10_SINGLE_VAULT_SEED, d10_mint.key().as_ref()], + bump, + )] + #[light_account(init, token, authority = [D10_SINGLE_VAULT_SEED, self.d10_mint.key(), &[params.vault_bump]], mint = d10_mint, owner = d10_vault_authority)] + pub d10_single_vault: UncheckedAccount<'info>, + + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub light_token_compressible_config: AccountInfo<'info>, + + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token CPI authority (required for token account creation) + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: Light token program for CPI + pub light_token_program: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs index d68f3a471d..03bef6532c 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs @@ -12,3 +12,4 @@ pub mod d6_account_types; pub mod d7_infra_names; pub mod d8_builder_paths; pub mod d9_seeds; +pub mod d10_token_accounts; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index 1d70c0823c..7eb4c412e3 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -23,6 +23,8 @@ pub use d6_account_types::*; pub use d7_infra_names::*; pub use d8_builder_paths::*; pub use d9_seeds::*; +pub mod d10_token_accounts; +pub use d10_token_accounts::*; pub use instruction_accounts::*; pub use instructions::{ d7_infra_names::{ @@ -243,6 +245,9 @@ pub mod csdk_anchor_full_derived_test { CreateMintWithMetadata, CreateMintWithMetadataParams, CreatePdasAndMintAuto, CreateThreeMints, CreateThreeMintsParams, CreateTwoMints, CreateTwoMintsParams, }, + instructions::d10_token_accounts::{ + D10SingleAta, D10SingleAtaParams, D10SingleVault, D10SingleVaultParams, + }, FullAutoWithMintParams, LIGHT_CPI_SIGNER, }; @@ -1259,4 +1264,36 @@ pub mod csdk_anchor_full_derived_test { ])?; Ok(()) } + + // ========================================================================= + // D10 Token Account Tests (auto-generated via #[light_account(init, token)]) + // ========================================================================= + + /// D10: Single vault with #[light_account(init, token, ...)] + /// This tests automatic code generation for token account creation. + /// The macro should generate CreateTokenAccountCpi in LightFinalize. + #[allow(unused_variables)] + pub fn d10_single_vault<'info>( + ctx: Context<'_, '_, '_, 'info, D10SingleVault<'info>>, + params: D10SingleVaultParams, + ) -> Result<()> { + // Token account creation is handled by the LightFinalize trait implementation + // generated by the #[light_account(init, token, ...)] macro. + // This handler can be empty - the macro handles everything. + Ok(()) + } + + /// D10: Single ATA with #[light_account(init, ata, ...)] + /// This tests automatic code generation for ATA creation. + /// The macro should generate create_associated_token_account_idempotent in LightFinalize. + #[allow(unused_variables)] + pub fn d10_single_ata<'info>( + ctx: Context<'_, '_, '_, 'info, D10SingleAta<'info>>, + params: D10SingleAtaParams, + ) -> Result<()> { + // ATA creation is handled by the LightFinalize trait implementation + // generated by the #[light_account(init, ata, ...)] macro. + // This handler can be empty - the macro handles everything. + Ok(()) + } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs new file mode 100644 index 0000000000..60ff5aad9c --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs @@ -0,0 +1,210 @@ +//! Integration tests for D10 token account macro features. +//! +//! Tests #[light_account(init, token, ...)] and #[light_account(init, ata, ...)] +//! automatic code generation for creating compressed token accounts. + +mod shared; + +use anchor_lang::{InstructionData, ToAccountMetas}; +use csdk_anchor_full_derived_test::d10_token_accounts::{ + D10SingleAtaParams, D10SingleVaultParams, D10_SINGLE_VAULT_AUTH_SEED, D10_SINGLE_VAULT_SEED, +}; +use light_compressible_client::{ + get_create_accounts_proof, InitializeRentFreeConfig, +}; +use light_macros::pubkey; +use light_program_test::{ + program_test::{setup_mock_program_data, LightProgramTest}, + ProgramTestConfig, Rpc, +}; +use light_sdk_types::LIGHT_TOKEN_PROGRAM_ID; +use light_token_sdk::token::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +const RENT_SPONSOR_PUBKEY: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); + +/// Test context for D10 token account tests +struct D10TestContext { + rpc: LightProgramTest, + payer: Keypair, + #[allow(dead_code)] + config_pda: Pubkey, + program_id: Pubkey, +} + +impl D10TestContext { + async fn new() -> Self { + let program_id = csdk_anchor_full_derived_test::ID; + let mut config = ProgramTestConfig::new_v2( + true, + Some(vec![("csdk_anchor_full_derived_test", program_id)]), + ); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR_PUBKEY, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + Self { + rpc, + payer, + config_pda, + program_id, + } + } + + async fn assert_onchain_exists(&mut self, account: &Pubkey) { + assert!( + self.rpc.get_account(*account).await.unwrap().is_some(), + "Account {} should exist on-chain", + account + ); + } + + /// Setup a mint for token-based tests. + /// Returns (mint_pubkey, compression_address, ata_pubkeys, mint_seed_keypair) + async fn setup_mint(&mut self) -> (Pubkey, [u8; 32], Vec, Keypair) { + shared::setup_create_mint( + &mut self.rpc, + &self.payer, + self.payer.pubkey(), // mint_authority + 9, // decimals + vec![], // no recipients initially + ) + .await + } +} + +/// Tests D10SingleVault: #[light_account(init, token, ...)] automatic code generation. +/// The macro should generate CreateTokenAccountCpi in LightFinalize. +#[tokio::test] +async fn test_d10_single_vault() { + let mut ctx = D10TestContext::new().await; + + // Setup mint + let (mint, _compression_addr, _atas, _mint_seed) = ctx.setup_mint().await; + + // Derive PDAs + let (d10_vault_authority, _auth_bump) = + Pubkey::find_program_address(&[D10_SINGLE_VAULT_AUTH_SEED], &ctx.program_id); + let (d10_single_vault, vault_bump) = + Pubkey::find_program_address(&[D10_SINGLE_VAULT_SEED, mint.as_ref()], &ctx.program_id); + + // Get proof (no PDA accounts for token-only instruction) + let proof_result = get_create_accounts_proof(&ctx.rpc, &ctx.program_id, vec![]) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D10SingleVault { + fee_payer: ctx.payer.pubkey(), + d10_mint: mint, + d10_vault_authority, + d10_single_vault, + light_token_compressible_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D10SingleVault { + params: D10SingleVaultParams { + create_accounts_proof: proof_result.create_accounts_proof, + vault_bump, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D10SingleVault instruction should succeed"); + + // Verify token vault exists on-chain + ctx.assert_onchain_exists(&d10_single_vault).await; +} + +/// Tests D10SingleAta: #[light_account(init, ata, ...)] automatic code generation. +/// The macro should generate create_associated_token_account_idempotent in LightFinalize. +#[tokio::test] +async fn test_d10_single_ata() { + let mut ctx = D10TestContext::new().await; + + // Setup mint + let (mint, _compression_addr, _atas, _mint_seed) = ctx.setup_mint().await; + + // The ATA owner will be the payer + let ata_owner = ctx.payer.pubkey(); + + // Derive the ATA address using Light Token SDK's derivation + let (d10_single_ata, ata_bump) = light_token_sdk::token::derive_token_ata(&ata_owner, &mint); + + // Get proof (no PDA accounts for ATA-only instruction) + let proof_result = get_create_accounts_proof(&ctx.rpc, &ctx.program_id, vec![]) + .await + .unwrap(); + + // Build instruction + let accounts = csdk_anchor_full_derived_test::accounts::D10SingleAta { + fee_payer: ctx.payer.pubkey(), + d10_ata_mint: mint, + d10_ata_owner: ata_owner, + d10_single_ata, + light_token_compressible_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D10SingleAta { + params: D10SingleAtaParams { + create_accounts_proof: proof_result.create_accounts_proof, + ata_bump, + }, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + ctx.rpc + .create_and_send_transaction(&[instruction], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("D10SingleAta instruction should succeed"); + + // Verify ATA exists on-chain + ctx.assert_onchain_exists(&d10_single_ata).await; +} From a7708d43dbca99ed2a276ca9d0c8b8719ac73f14 Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 20 Jan 2026 00:02:15 +0000 Subject: [PATCH 2/4] format --- .../macros/src/light_pdas/accounts/builder.rs | 2 +- .../macros/src/light_pdas/accounts/derive.rs | 7 +- .../src/light_pdas/accounts/light_account.rs | 341 ++++++++++-------- .../macros/src/light_pdas/accounts/parse.rs | 6 +- .../macros/src/light_pdas/accounts/token.rs | 15 +- .../macros/src/light_pdas/program/parsing.rs | 98 ++++- .../instructions/d10_token_accounts/mod.rs | 2 +- .../d10_token_accounts/single_ata.rs | 6 +- .../src/instructions/mod.rs | 2 +- .../csdk-anchor-full-derived-test/src/lib.rs | 4 +- .../tests/d10_token_accounts_test.rs | 8 +- 11 files changed, 310 insertions(+), 181 deletions(-) diff --git a/sdk-libs/macros/src/light_pdas/accounts/builder.rs b/sdk-libs/macros/src/light_pdas/accounts/builder.rs index 3287e54311..8c04881e16 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/builder.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/builder.rs @@ -163,7 +163,7 @@ impl LightAccountsBuilder { !self.parsed.token_account_fields.is_empty() } - /// Query: any #[light_account(init, ata, ...)] fields? + /// Query: any #[light_account(init, associated_token, ...)] fields? pub fn has_atas(&self) -> bool { !self.parsed.ata_fields.is_empty() } diff --git a/sdk-libs/macros/src/light_pdas/accounts/derive.rs b/sdk-libs/macros/src/light_pdas/accounts/derive.rs index 50363fde90..d55e6af909 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/derive.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/derive.rs @@ -63,9 +63,10 @@ pub(super) fn derive_light_accounts(input: &DeriveInput) -> Result, - #[light_account(init, ata, owner = wallet, mint = my_mint)] + #[light_account(init, associated_token, owner = wallet, mint = my_mint)] pub user_ata: Account<'info, CToken>, pub wallet: AccountInfo<'info>, @@ -189,7 +190,7 @@ mod tests { #[light_account(init, token, authority = [b"authority"], mint = my_mint, owner = fee_payer)] pub vault: Account<'info, CToken>, - #[light_account(init, ata, owner = wallet, mint = my_mint)] + #[light_account(init, associated_token, owner = wallet, mint = my_mint)] pub user_ata: Account<'info, CToken>, pub wallet: AccountInfo<'info>, diff --git a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs index a3e573aab3..7d68857fdf 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs @@ -25,9 +25,9 @@ pub(super) use crate::light_pdas::account::seed_extraction::extract_account_inne pub enum LightAccountType { #[default] Pda, // Default (no type specifier) - for PDAs - Mint, // `mint` keyword - for compressed mints - Token, // `token` keyword - for token accounts - Ata, // `ata` keyword - for ATAs + Mint, // `mint` keyword - for compressed mints + Token, // `token` keyword - for token accounts + AssociatedToken, // `associated_token` keyword - for ATAs } // ============================================================================ @@ -40,7 +40,7 @@ pub enum LightAccountField { Pda(Box), Mint(Box), TokenAccount(Box), - Ata(Box), + AssociatedToken(Box), } /// A field marked with #[light_account(init)] (PDA). @@ -61,16 +61,12 @@ pub struct TokenAccountField { pub field_ident: Ident, /// True if `init` keyword is present (generate creation code) pub has_init: bool, - /// Seeds for the token account PDA (from #[account(seeds = [...], bump)]) - pub seeds: Vec, /// Authority seeds for the PDA owner (from authority = [...] parameter) pub authority_seeds: Vec, /// Mint reference (extracted from seeds or explicit parameter) pub mint: Option, /// Owner reference (the PDA that owns this token account) pub owner: Option, - /// Compression-only flag (default: true when has_init = true) - pub compression_only: bool, } /// A field marked with #[light_account([init,] ata, ...)] (Associated Token Account). @@ -85,8 +81,6 @@ pub struct AtaField { pub mint: Expr, /// Bump seed (from #[account(seeds = [...], bump)]) pub bump: Option, - /// Compression-only flag (default: true when has_init = true) - pub compression_only: bool, } // ============================================================================ @@ -122,15 +116,15 @@ struct LightAccountArgs { impl Parse for LightAccountArgs { fn parse(input: ParseStream) -> syn::Result { - // First token must be `init`, `token`, or `ata` + // First token must be `init`, `token`, or `associated_token` let first: Ident = input.parse()?; - // Handle `token` or `ata` as first argument (mark-only mode, no init) - if first == "token" || first == "ata" { + // Handle `token` or `associated_token` as first argument (mark-only mode, no init) + if first == "token" || first == "associated_token" { let account_type = if first == "token" { LightAccountType::Token } else { - LightAccountType::Ata + LightAccountType::AssociatedToken }; let key_values = parse_token_ata_key_values(input, &first)?; return Ok(Self { @@ -144,7 +138,7 @@ impl Parse for LightAccountArgs { if first != "init" { return Err(Error::new_spanned( &first, - "First argument to #[light_account] must be `init`, `token`, or `ata`", + "First argument to #[light_account] must be `init`, `token`, or `associated_token`", )); } @@ -155,7 +149,7 @@ impl Parse for LightAccountArgs { while !input.is_empty() { input.parse::()?; - // Check if this is a type keyword (mint, token, ata) + // Check if this is a type keyword (mint, token, associated_token) if input.peek(Ident) { let lookahead = input.fork(); let ident: Ident = lookahead.parse()?; @@ -172,10 +166,10 @@ impl Parse for LightAccountArgs { // Parse remaining token-specific key-values key_values = parse_token_ata_key_values(input, &ident)?; break; - } else if ident == "ata" { + } else if ident == "associated_token" { input.parse::()?; // consume it - account_type = LightAccountType::Ata; - // Parse remaining ata-specific key-values + account_type = LightAccountType::AssociatedToken; + // Parse remaining associated_token-specific key-values key_values = parse_token_ata_key_values(input, &ident)?; break; } @@ -198,18 +192,20 @@ impl Parse for LightAccountArgs { } } -/// Parse key-value pairs for token and ata attributes. +/// Parse key-value pairs for token and associated_token attributes. /// Handles both bracketed arrays (authority = [...]) and simple values (owner = ident). +/// Supports shorthand syntax for mint, owner, bump (e.g., `mint` alone means `mint = mint`). fn parse_token_ata_key_values( input: ParseStream, account_type_name: &Ident, ) -> syn::Result> { let mut key_values = Vec::new(); + let mut seen_keys = std::collections::HashSet::new(); let valid_keys = if account_type_name == "token" { - &["authority", "mint", "owner", "compression_only"][..] + &["authority", "mint", "owner"][..] } else { - // ata - &["owner", "mint", "bump", "compression_only"][..] + // associated_token + &["owner", "mint", "bump"][..] }; while !input.is_empty() { @@ -222,6 +218,18 @@ fn parse_token_ata_key_values( let key: Ident = input.parse()?; let key_str = key.to_string(); + // Check for duplicate keys + if !seen_keys.insert(key_str.clone()) { + return Err(Error::new_spanned( + &key, + format!( + "Duplicate key `{}` in #[light_account({}, ...)]. Each key can only appear once.", + key_str, + account_type_name + ), + )); + } + if !valid_keys.contains(&key_str.as_str()) { return Err(Error::new_spanned( &key, @@ -235,24 +243,37 @@ fn parse_token_ata_key_values( )); } - input.parse::()?; - - // Handle bracketed content for authority seeds - let value: Expr = if key == "authority" && input.peek(syn::token::Bracket) { - let content; - syn::bracketed!(content in input); - // Parse as array expression - let mut elements = Vec::new(); - while !content.is_empty() { - let elem: Expr = content.parse()?; - elements.push(elem); - if content.peek(Token![,]) { - content.parse::()?; + // Check for shorthand syntax (key alone without =) for mint, owner, bump + let value: Expr = if input.peek(Token![=]) { + input.parse::()?; + + // Handle bracketed content for authority seeds + if key == "authority" && input.peek(syn::token::Bracket) { + let content; + syn::bracketed!(content in input); + // Parse as array expression + let mut elements = Vec::new(); + while !content.is_empty() { + let elem: Expr = content.parse()?; + elements.push(elem); + if content.peek(Token![,]) { + content.parse::()?; + } } + syn::parse_quote!([#(#elements),*]) + } else { + input.parse()? } - syn::parse_quote!([#(#elements),*]) } else { - input.parse()? + // Shorthand: key alone means key = key (for mint, owner, bump) + if key_str == "mint" || key_str == "owner" || key_str == "bump" { + syn::parse_quote!(#key) + } else { + return Err(Error::new_spanned( + &key, + format!("`{}` requires a value (e.g., `{} = ...`)", key_str, key_str), + )); + } }; key_values.push(KeyValue { key, value }); @@ -303,9 +324,11 @@ pub(super) fn parse_light_account_attr( LightAccountType::Token => Ok(Some(LightAccountField::TokenAccount(Box::new( build_token_account_field(field_ident, &args.key_values, args.has_init, attr)?, )))), - LightAccountType::Ata => Ok(Some(LightAccountField::Ata(Box::new( - build_ata_field(field_ident, &args.key_values, args.has_init, attr)?, - )))), + LightAccountType::AssociatedToken => { + Ok(Some(LightAccountField::AssociatedToken(Box::new( + build_ata_field(field_ident, &args.key_values, args.has_init, attr)?, + )))) + } }; } } @@ -484,22 +507,18 @@ fn build_token_account_field( let mut authority: Option = None; let mut mint: Option = None; let mut owner: Option = None; - let mut compression_only: Option = None; for kv in key_values { match kv.key.to_string().as_str() { "authority" => authority = Some(kv.value.clone()), "mint" => mint = Some(kv.value.clone()), "owner" => owner = Some(kv.value.clone()), - "compression_only" => { - compression_only = Some(expr_to_bool(&kv.value, "compression_only")?); - } other => { return Err(Error::new_spanned( &kv.key, format!( "Unknown argument `{other}` for token. \ - Expected: authority, mint, owner, compression_only" + Expected: authority, mint, owner" ), )); } @@ -507,33 +526,34 @@ fn build_token_account_field( } // Validate required fields for init mode - if has_init { - if authority.is_none() { - return Err(Error::new_spanned( - attr, - "#[light_account(init, token, ...)] requires `authority = [...]` parameter", - )); - } + if has_init && authority.is_none() { + return Err(Error::new_spanned( + attr, + "#[light_account(init, token, ...)] requires `authority = [...]` parameter", + )); } // Extract authority seeds from the array expression let authority_seeds = if let Some(ref auth_expr) = authority { - extract_array_elements(auth_expr)? + let seeds = extract_array_elements(auth_expr)?; + if has_init && seeds.is_empty() { + return Err(Error::new_spanned( + auth_expr, + "Empty authority seeds `authority = []` not allowed for token account initialization. \ + Token accounts require at least one seed to derive the PDA owner.", + )); + } + seeds } else { Vec::new() }; - // Default compression_only to true when init is present - let compression_only = compression_only.unwrap_or(has_init); - Ok(TokenAccountField { field_ident: field_ident.clone(), has_init, - seeds: Vec::new(), // Seeds will be extracted from #[account(seeds = [...])] later authority_seeds, mint, owner, - compression_only, }) } @@ -547,22 +567,18 @@ fn build_ata_field( let mut owner: Option = None; let mut mint: Option = None; let mut bump: Option = None; - let mut compression_only: Option = None; for kv in key_values { match kv.key.to_string().as_str() { "owner" => owner = Some(kv.value.clone()), "mint" => mint = Some(kv.value.clone()), "bump" => bump = Some(kv.value.clone()), - "compression_only" => { - compression_only = Some(expr_to_bool(&kv.value, "compression_only")?); - } other => { return Err(Error::new_spanned( &kv.key, format!( - "Unknown argument `{other}` in #[light_account(ata, ...)]. \ - Allowed: owner, mint, bump, compression_only" + "Unknown argument `{other}` in #[light_account(associated_token, ...)]. \ + Allowed: owner, mint, bump" ), )); } @@ -573,26 +589,22 @@ fn build_ata_field( let owner = owner.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account([init,] ata, ...)] requires `owner` parameter", + "#[light_account([init,] associated_token, ...)] requires `owner` parameter", ) })?; let mint = mint.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account([init,] ata, ...)] requires `mint` parameter", + "#[light_account([init,] associated_token, ...)] requires `mint` parameter", ) })?; - // Default compression_only to true when init is present - let compression_only = compression_only.unwrap_or(has_init); - Ok(AtaField { field_ident: field_ident.clone(), has_init, owner, mint, bump, - compression_only, }) } @@ -609,39 +621,6 @@ fn expr_to_ident(expr: &Expr, field_name: &str) -> Result { } } -/// Convert an expression to a boolean (for flags like compression_only). -fn expr_to_bool(expr: &Expr, field_name: &str) -> Result { - match expr { - Expr::Lit(lit) => { - if let syn::Lit::Bool(b) = &lit.lit { - Ok(b.value) - } else { - Err(Error::new_spanned( - expr, - format!("`{field_name}` must be `true` or `false`"), - )) - } - } - Expr::Path(path) => { - let ident = path.path.get_ident().ok_or_else(|| { - Error::new_spanned(expr, format!("`{field_name}` must be `true` or `false`")) - })?; - match ident.to_string().as_str() { - "true" => Ok(true), - "false" => Ok(false), - _ => Err(Error::new_spanned( - expr, - format!("`{field_name}` must be `true` or `false`"), - )), - } - } - _ => Err(Error::new_spanned( - expr, - format!("`{field_name}` must be `true` or `false`"), - )), - } -} - /// Extract elements from an array expression. fn extract_array_elements(expr: &Expr) -> Result, syn::Error> { match expr { @@ -907,34 +886,12 @@ mod tests { LightAccountField::TokenAccount(token) => { assert_eq!(token.field_ident.to_string(), "vault"); assert!(token.has_init); - assert!(token.compression_only); // default true when has_init assert!(!token.authority_seeds.is_empty()); } _ => panic!("Expected TokenAccount field"), } } - #[test] - fn test_parse_token_init_with_compression_only_false() { - let field: syn::Field = parse_quote! { - #[light_account(init, token, authority = [b"authority"], compression_only = false)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::TokenAccount(token) => { - assert!(!token.compression_only); - } - _ => panic!("Expected TokenAccount field"), - } - } - #[test] fn test_parse_token_init_missing_authority_fails() { let field: syn::Field = parse_quote! { @@ -950,14 +907,14 @@ mod tests { } // ======================================================================== - // ATA Tests + // Associated Token Tests // ======================================================================== #[test] - fn test_parse_ata_mark_only_returns_none() { + fn test_parse_associated_token_mark_only_returns_none() { // Mark-only mode (no init) should return None for LightAccounts derive let field: syn::Field = parse_quote! { - #[light_account(ata, owner = owner, mint = mint)] + #[light_account(associated_token, owner = owner, mint = mint)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -968,9 +925,9 @@ mod tests { } #[test] - fn test_parse_ata_init_creates_field() { + fn test_parse_associated_token_init_creates_field() { let field: syn::Field = parse_quote! { - #[light_account(init, ata, owner = owner, mint = mint)] + #[light_account(init, associated_token, owner = owner, mint = mint)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -981,19 +938,75 @@ mod tests { assert!(result.is_some()); match result.unwrap() { - LightAccountField::Ata(ata) => { + LightAccountField::AssociatedToken(ata) => { assert_eq!(ata.field_ident.to_string(), "user_ata"); assert!(ata.has_init); - assert!(ata.compression_only); // default true when has_init } - _ => panic!("Expected Ata field"), + _ => panic!("Expected AssociatedToken field"), } } #[test] - fn test_parse_ata_init_with_compression_only_false() { + fn test_parse_associated_token_init_missing_owner_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, associated_token, mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("owner")); + } + + #[test] + fn test_parse_associated_token_init_missing_mint_fails() { let field: syn::Field = parse_quote! { - #[light_account(init, ata, owner = owner, mint = mint, compression_only = false)] + #[light_account(init, associated_token, owner = owner)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("mint")); + } + + #[test] + fn test_parse_token_unknown_argument_fails() { + let field: syn::Field = parse_quote! { + #[light_account(token, authority = [b"auth"], unknown = foo)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("unknown")); + } + + #[test] + fn test_parse_associated_token_unknown_argument_fails() { + let field: syn::Field = parse_quote! { + #[light_account(associated_token, owner = owner, mint = mint, unknown = foo)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("unknown")); + } + + #[test] + fn test_parse_associated_token_shorthand_syntax() { + // Test shorthand syntax: mint, owner, bump without = value + let field: syn::Field = parse_quote! { + #[light_account(init, associated_token, owner, mint, bump)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1004,31 +1017,39 @@ mod tests { assert!(result.is_some()); match result.unwrap() { - LightAccountField::Ata(ata) => { - assert!(!ata.compression_only); + LightAccountField::AssociatedToken(ata) => { + assert_eq!(ata.field_ident.to_string(), "user_ata"); + assert!(ata.has_init); + assert!(ata.bump.is_some()); } - _ => panic!("Expected Ata field"), + _ => panic!("Expected AssociatedToken field"), } } #[test] - fn test_parse_ata_init_missing_owner_fails() { + fn test_parse_token_duplicate_key_fails() { + // F006: Duplicate keys should be rejected let field: syn::Field = parse_quote! { - #[light_account(init, ata, mint = mint)] - pub user_ata: Account<'info, CToken> + #[light_account(token, authority = [b"auth1"], authority = [b"auth2"])] + pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); let result = parse_light_account_attr(&field, &ident); assert!(result.is_err()); let err = result.err().unwrap().to_string(); - assert!(err.contains("owner")); + assert!( + err.contains("Duplicate key"), + "Expected error about duplicate key, got: {}", + err + ); } #[test] - fn test_parse_ata_init_missing_mint_fails() { + fn test_parse_associated_token_duplicate_key_fails() { + // F006: Duplicate keys in associated_token should also be rejected let field: syn::Field = parse_quote! { - #[light_account(init, ata, owner = owner)] + #[light_account(init, associated_token, owner = foo, owner = bar, mint)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1036,13 +1057,18 @@ mod tests { let result = parse_light_account_attr(&field, &ident); assert!(result.is_err()); let err = result.err().unwrap().to_string(); - assert!(err.contains("mint")); + assert!( + err.contains("Duplicate key"), + "Expected error about duplicate key, got: {}", + err + ); } #[test] - fn test_parse_token_unknown_argument_fails() { + fn test_parse_token_init_empty_authority_fails() { + // F007: Empty authority seeds with init should be rejected let field: syn::Field = parse_quote! { - #[light_account(token, authority = [b"auth"], unknown = foo)] + #[light_account(init, token, authority = [])] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1050,20 +1076,25 @@ mod tests { let result = parse_light_account_attr(&field, &ident); assert!(result.is_err()); let err = result.err().unwrap().to_string(); - assert!(err.contains("unknown")); + assert!( + err.contains("Empty authority seeds"), + "Expected error about empty authority seeds, got: {}", + err + ); } #[test] - fn test_parse_ata_unknown_argument_fails() { + fn test_parse_token_non_init_empty_authority_allowed() { + // F007: Empty authority seeds without init should be allowed (mark-only mode) let field: syn::Field = parse_quote! { - #[light_account(ata, owner = owner, mint = mint, unknown = foo)] - pub user_ata: Account<'info, CToken> + #[light_account(token, authority = [])] + pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); + // Mark-only mode returns Ok(None) let result = parse_light_account_attr(&field, &ident); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!(err.contains("unknown")); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); } } diff --git a/sdk-libs/macros/src/light_pdas/accounts/parse.rs b/sdk-libs/macros/src/light_pdas/accounts/parse.rs index d77cc58603..93d34d7fdf 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/parse.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/parse.rs @@ -10,7 +10,9 @@ use syn::{ }; // Import unified parsing from light_account module -use super::light_account::{parse_light_account_attr, AtaField, LightAccountField, TokenAccountField}; +use super::light_account::{ + parse_light_account_attr, AtaField, LightAccountField, TokenAccountField, +}; // Import LightMintField from mint module (for type export) pub(super) use super::mint::LightMintField; @@ -251,7 +253,7 @@ pub(super) fn parse_light_accounts_struct( LightAccountField::Pda(pda) => rentfree_fields.push((*pda).into()), LightAccountField::Mint(mint) => light_mint_fields.push(*mint), LightAccountField::TokenAccount(token) => token_account_fields.push(*token), - LightAccountField::Ata(ata) => ata_fields.push(*ata), + LightAccountField::AssociatedToken(ata) => ata_fields.push(*ata), } continue; // Field processed, move to next } diff --git a/sdk-libs/macros/src/light_pdas/accounts/token.rs b/sdk-libs/macros/src/light_pdas/accounts/token.rs index df48165072..f9583d2973 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/token.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/token.rs @@ -21,8 +21,10 @@ use proc_macro2::TokenStream; use quote::quote; -use super::light_account::{AtaField, TokenAccountField}; -use super::mint::InfraRefs; +use super::{ + light_account::{AtaField, TokenAccountField}, + mint::InfraRefs, +}; /// Generate token account creation CPI code for a single token account field. /// @@ -59,8 +61,10 @@ pub(super) fn generate_token_account_cpi( .iter() .enumerate() .map(|(i, seed)| { - let val_name = syn::Ident::new(&format!("__seed_{}", i), proc_macro2::Span::call_site()); - let ref_name = syn::Ident::new(&format!("__seed_{}_ref", i), proc_macro2::Span::call_site()); + let val_name = + syn::Ident::new(&format!("__seed_{}", i), proc_macro2::Span::call_site()); + let ref_name = + syn::Ident::new(&format!("__seed_{}_ref", i), proc_macro2::Span::call_site()); quote! { let #val_name = #seed; let #ref_name: &[u8] = #val_name.as_ref(); @@ -69,7 +73,8 @@ pub(super) fn generate_token_account_cpi( .collect(); let seed_refs: Vec = (0..authority_seeds.len()) .map(|i| { - let ref_name = syn::Ident::new(&format!("__seed_{}_ref", i), proc_macro2::Span::call_site()); + let ref_name = + syn::Ident::new(&format!("__seed_{}_ref", i), proc_macro2::Span::call_site()); quote! { #ref_name } }) .collect(); diff --git a/sdk-libs/macros/src/light_pdas/program/parsing.rs b/sdk-libs/macros/src/light_pdas/program/parsing.rs index a1c561181f..13f45472e7 100644 --- a/sdk-libs/macros/src/light_pdas/program/parsing.rs +++ b/sdk-libs/macros/src/light_pdas/program/parsing.rs @@ -420,12 +420,29 @@ fn is_delegation_body(block: &syn::Block) -> bool { } /// Check if any argument in the call is `ctx` (moving the context). +/// Detects: ctx, &ctx, &mut ctx, ctx.clone(), ctx.into(), etc. fn call_has_ctx_arg(args: &syn::punctuated::Punctuated) -> bool { for arg in args { - if let syn::Expr::Path(path) = arg { - if path.path.is_ident("ctx") { - return true; + match arg { + // Direct ctx identifier + syn::Expr::Path(path) if path.path.is_ident("ctx") => return true, + // Reference patterns: &ctx, &mut ctx + syn::Expr::Reference(ref_expr) => { + if let syn::Expr::Path(p) = &*ref_expr.expr { + if p.path.is_ident("ctx") { + return true; + } + } } + // Method call patterns: ctx.clone(), ctx.into() + syn::Expr::MethodCall(method_call) => { + if let syn::Expr::Path(p) = &*method_call.receiver { + if p.path.is_ident("ctx") { + return true; + } + } + } + _ => {} } } false @@ -486,3 +503,78 @@ pub fn wrap_function_with_light(fn_item: &ItemFn, params_ident: &Ident) -> ItemF } } } + +#[cfg(test)] +mod tests { + use syn::punctuated::Punctuated; + + use super::*; + + fn parse_args(code: &str) -> Punctuated { + let call: syn::ExprCall = syn::parse_str(&format!("f({})", code)).unwrap(); + call.args + } + + #[test] + fn test_call_has_ctx_arg_direct() { + // F001: Direct ctx identifier + let args = parse_args("ctx"); + assert!(call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_reference() { + // F001: Reference pattern &ctx + let args = parse_args("&ctx"); + assert!(call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_mut_reference() { + // F001: Mutable reference pattern &mut ctx + let args = parse_args("&mut ctx"); + assert!(call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_clone() { + // F001: Method call ctx.clone() + let args = parse_args("ctx.clone()"); + assert!(call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_into() { + // F001: Method call ctx.into() + let args = parse_args("ctx.into()"); + assert!(call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_other_name() { + // Non-ctx identifier should return false + let args = parse_args("context"); + assert!(!call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_method_on_other() { + // Method call on non-ctx receiver + let args = parse_args("other.clone()"); + assert!(!call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_multiple_args() { + // F001: ctx among multiple arguments + let args = parse_args("foo, ctx.clone(), bar"); + assert!(call_has_ctx_arg(&args)); + } + + #[test] + fn test_call_has_ctx_arg_empty() { + // Empty args should return false + let args = parse_args(""); + assert!(!call_has_ctx_arg(&args)); + } +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs index 79544386c0..6d60e5fde0 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/mod.rs @@ -1,6 +1,6 @@ //! D10 Test: Token Account and ATA creation via macro //! -//! Tests #[light_account(init, token, ...)] and #[light_account(init, ata, ...)] +//! Tests #[light_account(init, token, ...)] and #[light_account(init, associated_token, ...)] //! macro code generation for creating compressed token accounts. //! //! These tests verify: diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs index 68820f2a1a..3356240161 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_ata.rs @@ -1,6 +1,6 @@ //! D10 Test: Single ATA creation via macro //! -//! Tests #[light_account(init, ata, ...)] automatic code generation +//! Tests #[light_account(init, associated_token, ...)] automatic code generation //! for creating a single compressed token associated token account. //! //! This differs from D5 tests which use mark-only mode and manual creation. @@ -19,7 +19,7 @@ pub struct D10SingleAtaParams { pub ata_bump: u8, } -/// Tests #[light_account(init, ata, ...)] automatic code generation. +/// Tests #[light_account(init, associated_token, ...)] automatic code generation. /// The macro should generate CreateTokenAtaCpi in LightFinalize. #[derive(Accounts, LightAccounts)] #[instruction(params: D10SingleAtaParams)] @@ -35,7 +35,7 @@ pub struct D10SingleAta<'info> { /// ATA account - macro should generate creation code. #[account(mut)] - #[light_account(init, ata, owner = d10_ata_owner, mint = d10_ata_mint, bump = params.ata_bump)] + #[light_account(init, associated_token, owner = d10_ata_owner, mint = d10_ata_mint, bump = params.ata_bump)] pub d10_single_ata: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs index 03bef6532c..f26fd8648c 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/mod.rs @@ -7,9 +7,9 @@ //! - d8_builder_paths: Builder code generation paths //! - d9_seeds: Seed expression classification +pub mod d10_token_accounts; pub mod d5_markers; pub mod d6_account_types; pub mod d7_infra_names; pub mod d8_builder_paths; pub mod d9_seeds; -pub mod d10_token_accounts; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index 7eb4c412e3..72a02d6f45 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -1283,7 +1283,7 @@ pub mod csdk_anchor_full_derived_test { Ok(()) } - /// D10: Single ATA with #[light_account(init, ata, ...)] + /// D10: Single ATA with #[light_account(init, associated_token, ...)] /// This tests automatic code generation for ATA creation. /// The macro should generate create_associated_token_account_idempotent in LightFinalize. #[allow(unused_variables)] @@ -1292,7 +1292,7 @@ pub mod csdk_anchor_full_derived_test { params: D10SingleAtaParams, ) -> Result<()> { // ATA creation is handled by the LightFinalize trait implementation - // generated by the #[light_account(init, ata, ...)] macro. + // generated by the #[light_account(init, associated_token, ...)] macro. // This handler can be empty - the macro handles everything. Ok(()) } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs index 60ff5aad9c..06c5029af2 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs @@ -1,6 +1,6 @@ //! Integration tests for D10 token account macro features. //! -//! Tests #[light_account(init, token, ...)] and #[light_account(init, ata, ...)] +//! Tests #[light_account(init, token, ...)] and #[light_account(init, associated_token, ...)] //! automatic code generation for creating compressed token accounts. mod shared; @@ -9,9 +9,7 @@ use anchor_lang::{InstructionData, ToAccountMetas}; use csdk_anchor_full_derived_test::d10_token_accounts::{ D10SingleAtaParams, D10SingleVaultParams, D10_SINGLE_VAULT_AUTH_SEED, D10_SINGLE_VAULT_SEED, }; -use light_compressible_client::{ - get_create_accounts_proof, InitializeRentFreeConfig, -}; +use light_client::interface::{get_create_accounts_proof, InitializeRentFreeConfig}; use light_macros::pubkey; use light_program_test::{ program_test::{setup_mock_program_data, LightProgramTest}, @@ -151,7 +149,7 @@ async fn test_d10_single_vault() { ctx.assert_onchain_exists(&d10_single_vault).await; } -/// Tests D10SingleAta: #[light_account(init, ata, ...)] automatic code generation. +/// Tests D10SingleAta: #[light_account(init, associated_token, ...)] automatic code generation. /// The macro should generate create_associated_token_account_idempotent in LightFinalize. #[tokio::test] async fn test_d10_single_ata() { From dbaf064f4c0b4594c1d32646cd88dd7d7190fd65 Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 20 Jan 2026 00:35:36 +0000 Subject: [PATCH 3/4] chore: make ctx: Context arg naming general --- .../src/light_pdas/program/instructions.rs | 6 +- .../macros/src/light_pdas/program/parsing.rs | 173 +++++++++++++++--- .../macros/src/light_pdas/program/visitors.rs | 84 ++++++--- .../csdk-anchor-full-derived-test/src/lib.rs | 15 +- 4 files changed, 218 insertions(+), 60 deletions(-) diff --git a/sdk-libs/macros/src/light_pdas/program/instructions.rs b/sdk-libs/macros/src/light_pdas/program/instructions.rs index 220cfd8a2b..c7e9ff538a 100644 --- a/sdk-libs/macros/src/light_pdas/program/instructions.rs +++ b/sdk-libs/macros/src/light_pdas/program/instructions.rs @@ -529,10 +529,12 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result 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)> { +/// Extract the Context type name and context parameter name from a function's parameters. +/// Returns (struct_name, params_ident, ctx_ident) if found. +/// The ctx_ident is the actual parameter name (e.g., "ctx", "context", "anchor_ctx"). +pub fn extract_context_and_params(fn_item: &ItemFn) -> Option<(String, Ident, Ident)> { let mut context_type = None; let mut params_ident = None; + let mut ctx_ident = None; for input in &fn_item.sig.inputs { if let syn::FnArg::Typed(pat_type) = input { @@ -362,6 +364,9 @@ pub fn extract_context_and_params(fn_item: &ItemFn) -> Option<(String, Ident)> { if let syn::Type::Path(type_path) = &*pat_type.ty { if let Some(segment) = type_path.path.segments.last() { if segment.ident == "Context" { + // Capture the context parameter name (e.g., ctx, context, anchor_ctx) + ctx_ident = Some(pat_ident.ident.clone()); + // 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>) @@ -376,13 +381,14 @@ pub fn extract_context_and_params(fn_item: &ItemFn) -> Option<(String, Ident)> { } } } + continue; // Don't consider ctx as params } } } - // Track potential params argument (not ctx, not signer-like names) + // Track potential params argument (not the context param, not signer-like names) let name = pat_ident.ident.to_string(); - if name != "ctx" && !name.contains("signer") && !name.contains("bump") { + if !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()); @@ -392,8 +398,8 @@ pub fn extract_context_and_params(fn_item: &ItemFn) -> Option<(String, Ident)> { } } - match (context_type, params_ident) { - (Some(ctx), Some(params)) => Some((ctx, params)), + match (context_type, params_ident, ctx_ident) { + (Some(ctx_type), Some(params), Some(ctx_name)) => Some((ctx_type, params, ctx_name)), _ => None, } } @@ -401,7 +407,8 @@ pub fn extract_context_and_params(fn_item: &ItemFn) -> Option<(String, Ident)> { /// Check if a function body is a simple delegation (single expression that moves ctx). /// Returns true for patterns like `crate::module::function(ctx, params)`. /// Does NOT match simple returns like `Ok(())` since those don't consume ctx. -fn is_delegation_body(block: &syn::Block) -> bool { +/// `ctx_name` is the context parameter name to look for (e.g., "ctx", "context"). +fn is_delegation_body(block: &syn::Block, ctx_name: &str) -> bool { // Check if block has exactly one statement that's an expression if block.stmts.len() != 1 { return false; @@ -410,8 +417,8 @@ fn is_delegation_body(block: &syn::Block) -> bool { syn::Stmt::Expr(expr, _) => { // Check if it's a function call that takes ctx as an argument match expr { - syn::Expr::Call(call) => call_has_ctx_arg(&call.args), - syn::Expr::MethodCall(call) => call_has_ctx_arg(&call.args), + syn::Expr::Call(call) => call_has_ctx_arg(&call.args, ctx_name), + syn::Expr::MethodCall(call) => call_has_ctx_arg(&call.args, ctx_name), _ => false, } } @@ -419,17 +426,21 @@ fn is_delegation_body(block: &syn::Block) -> bool { } } -/// Check if any argument in the call is `ctx` (moving the context). +/// Check if any argument in the call is the context param (moving the context). /// Detects: ctx, &ctx, &mut ctx, ctx.clone(), ctx.into(), etc. -fn call_has_ctx_arg(args: &syn::punctuated::Punctuated) -> bool { +/// `ctx_name` is the context parameter name to look for (e.g., "ctx", "context"). +fn call_has_ctx_arg( + args: &syn::punctuated::Punctuated, + ctx_name: &str, +) -> bool { for arg in args { match arg { // Direct ctx identifier - syn::Expr::Path(path) if path.path.is_ident("ctx") => return true, + syn::Expr::Path(path) if path.path.is_ident(ctx_name) => return true, // Reference patterns: &ctx, &mut ctx syn::Expr::Reference(ref_expr) => { if let syn::Expr::Path(p) = &*ref_expr.expr { - if p.path.is_ident("ctx") { + if p.path.is_ident(ctx_name) { return true; } } @@ -437,7 +448,7 @@ fn call_has_ctx_arg(args: &syn::punctuated::Punctuated { if let syn::Expr::Path(p) = &*method_call.receiver { - if p.path.is_ident("ctx") { + if p.path.is_ident(ctx_name) { return true; } } @@ -449,7 +460,12 @@ fn call_has_ctx_arg(args: &syn::punctuated::Punctuated ItemFn { +/// `ctx_name` is the parameter name used for the Context (e.g., "ctx", "context", "anchor_ctx"). +pub fn wrap_function_with_light( + fn_item: &ItemFn, + params_ident: &Ident, + ctx_name: &Ident, +) -> ItemFn { let fn_vis = &fn_item.vis; let fn_sig = &fn_item.sig; let fn_block = &fn_item.block; @@ -457,7 +473,8 @@ pub fn wrap_function_with_light(fn_item: &ItemFn, params_ident: &Ident) -> ItemF // Check if this handler delegates to another function (which moves ctx) // In that case, skip finalize since the delegated function handles everything - let is_delegation = is_delegation_body(fn_block); + let ctx_name_str = ctx_name.to_string(); + let is_delegation = is_delegation_body(fn_block, &ctx_name_str); if is_delegation { // For delegation handlers, just add pre_init before the delegation call @@ -466,7 +483,7 @@ pub fn wrap_function_with_light(fn_item: &ItemFn, params_ident: &Ident) -> ItemF #fn_vis #fn_sig { // Phase 1: Pre-init (creates mints via CPI context write, registers compressed addresses) use light_sdk::interface::{LightPreInit, LightFinalize}; - let _ = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) + let _ = #ctx_name.accounts.light_pre_init(#ctx_name.remaining_accounts, &#params_ident) .map_err(|e: light_sdk::error::LightSdkError| -> solana_program_error::ProgramError { e.into() })?; @@ -482,7 +499,7 @@ pub fn wrap_function_with_light(fn_item: &ItemFn, params_ident: &Ident) -> ItemF #fn_vis #fn_sig { // Phase 1: Pre-init (creates mints via CPI context write, registers compressed addresses) use light_sdk::interface::{LightPreInit, LightFinalize}; - let __has_pre_init = ctx.accounts.light_pre_init(ctx.remaining_accounts, &#params_ident) + let __has_pre_init = #ctx_name.accounts.light_pre_init(#ctx_name.remaining_accounts, &#params_ident) .map_err(|e: light_sdk::error::LightSdkError| -> solana_program_error::ProgramError { e.into() })?; @@ -493,7 +510,7 @@ pub fn wrap_function_with_light(fn_item: &ItemFn, params_ident: &Ident) -> ItemF __user_result?; // Phase 2: Finalize (creates token accounts/ATAs via CPI) - ctx.accounts.light_finalize(ctx.remaining_accounts, &#params_ident, __has_pre_init) + #ctx_name.accounts.light_finalize(#ctx_name.remaining_accounts, &#params_ident, __has_pre_init) .map_err(|e: light_sdk::error::LightSdkError| -> solana_program_error::ProgramError { e.into() })?; @@ -519,62 +536,158 @@ mod tests { fn test_call_has_ctx_arg_direct() { // F001: Direct ctx identifier let args = parse_args("ctx"); - assert!(call_has_ctx_arg(&args)); + assert!(call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_reference() { // F001: Reference pattern &ctx let args = parse_args("&ctx"); - assert!(call_has_ctx_arg(&args)); + assert!(call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_mut_reference() { // F001: Mutable reference pattern &mut ctx let args = parse_args("&mut ctx"); - assert!(call_has_ctx_arg(&args)); + assert!(call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_clone() { // F001: Method call ctx.clone() let args = parse_args("ctx.clone()"); - assert!(call_has_ctx_arg(&args)); + assert!(call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_into() { // F001: Method call ctx.into() let args = parse_args("ctx.into()"); - assert!(call_has_ctx_arg(&args)); + assert!(call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_other_name() { - // Non-ctx identifier should return false + // Non-ctx identifier should return false when looking for "ctx" let args = parse_args("context"); - assert!(!call_has_ctx_arg(&args)); + assert!(!call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_method_on_other() { // Method call on non-ctx receiver let args = parse_args("other.clone()"); - assert!(!call_has_ctx_arg(&args)); + assert!(!call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_multiple_args() { // F001: ctx among multiple arguments let args = parse_args("foo, ctx.clone(), bar"); - assert!(call_has_ctx_arg(&args)); + assert!(call_has_ctx_arg(&args, "ctx")); } #[test] fn test_call_has_ctx_arg_empty() { // Empty args should return false let args = parse_args(""); - assert!(!call_has_ctx_arg(&args)); + assert!(!call_has_ctx_arg(&args, "ctx")); + } + + // Tests for dynamic context name detection + #[test] + fn test_call_has_ctx_arg_custom_name_context() { + // Direct identifier with custom name "context" + let args = parse_args("context"); + assert!(call_has_ctx_arg(&args, "context")); + } + + #[test] + fn test_call_has_ctx_arg_custom_name_anchor_ctx() { + // Direct identifier with custom name "anchor_ctx" + let args = parse_args("anchor_ctx"); + assert!(call_has_ctx_arg(&args, "anchor_ctx")); + } + + #[test] + fn test_call_has_ctx_arg_custom_name_reference() { + // Reference pattern with custom name + let args = parse_args("&my_context"); + assert!(call_has_ctx_arg(&args, "my_context")); + } + + #[test] + fn test_call_has_ctx_arg_custom_name_method_call() { + // Method call with custom name + let args = parse_args("c.clone()"); + assert!(call_has_ctx_arg(&args, "c")); + } + + #[test] + fn test_call_has_ctx_arg_wrong_custom_name() { + // Looking for wrong name should return false + let args = parse_args("ctx"); + assert!(!call_has_ctx_arg(&args, "context")); + } + + #[test] + fn test_extract_context_and_params_standard() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(ctx: Context, params: Params) -> Result<()> { + Ok(()) + } + }; + let result = extract_context_and_params(&fn_item); + assert!(result.is_some()); + let (ctx_type, params_ident, ctx_ident) = result.unwrap(); + assert_eq!(ctx_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "params"); + assert_eq!(ctx_ident.to_string(), "ctx"); + } + + #[test] + fn test_extract_context_and_params_custom_context_name() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(context: Context, params: Params) -> Result<()> { + Ok(()) + } + }; + let result = extract_context_and_params(&fn_item); + assert!(result.is_some()); + let (ctx_type, params_ident, ctx_ident) = result.unwrap(); + assert_eq!(ctx_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "params"); + assert_eq!(ctx_ident.to_string(), "context"); + } + + #[test] + fn test_extract_context_and_params_anchor_ctx_name() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(anchor_ctx: Context, data: Data) -> Result<()> { + Ok(()) + } + }; + let result = extract_context_and_params(&fn_item); + assert!(result.is_some()); + let (ctx_type, params_ident, ctx_ident) = result.unwrap(); + assert_eq!(ctx_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "data"); + assert_eq!(ctx_ident.to_string(), "anchor_ctx"); + } + + #[test] + fn test_extract_context_and_params_single_letter_name() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(c: Context, p: Params) -> Result<()> { + Ok(()) + } + }; + let result = extract_context_and_params(&fn_item); + assert!(result.is_some()); + let (ctx_type, params_ident, ctx_ident) = result.unwrap(); + assert_eq!(ctx_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "p"); + assert_eq!(ctx_ident.to_string(), "c"); } } diff --git a/sdk-libs/macros/src/light_pdas/program/visitors.rs b/sdk-libs/macros/src/light_pdas/program/visitors.rs index 6477587f31..192e77c04a 100644 --- a/sdk-libs/macros/src/light_pdas/program/visitors.rs +++ b/sdk-libs/macros/src/light_pdas/program/visitors.rs @@ -38,6 +38,8 @@ pub struct FieldExtractor<'ast, 'cfg> { extract_data: bool, /// Field names to exclude from results excluded: &'cfg [&'cfg str], + /// The context parameter name (e.g., "ctx", "context", "anchor_ctx") + ctx_name: &'cfg str, /// Collected field references (avoids cloning during traversal) fields: Vec<&'ast Ident>, /// Track seen field names for deduplication @@ -47,12 +49,22 @@ pub struct FieldExtractor<'ast, 'cfg> { impl<'ast, 'cfg> FieldExtractor<'ast, 'cfg> { /// Create an extractor for ctx.field and ctx.accounts.field patterns. /// + /// Uses the default context name "ctx". /// Excludes common infrastructure fields like fee_payer, rent_sponsor, etc. pub fn ctx_fields(excluded: &'cfg [&'cfg str]) -> Self { + Self::ctx_fields_with_name(excluded, "ctx") + } + + /// Create an extractor for ctx.field and ctx.accounts.field patterns with a custom context name. + /// + /// `ctx_name` is the context parameter name (e.g., "ctx", "context", "anchor_ctx"). + /// Excludes common infrastructure fields like fee_payer, rent_sponsor, etc. + pub fn ctx_fields_with_name(excluded: &'cfg [&'cfg str], ctx_name: &'cfg str) -> Self { Self { extract_ctx: true, extract_data: false, excluded, + ctx_name, fields: Vec::new(), seen: HashSet::new(), } @@ -64,6 +76,7 @@ impl<'ast, 'cfg> FieldExtractor<'ast, 'cfg> { extract_ctx: false, extract_data: true, excluded: &[], + ctx_name: "ctx", // Not used for data extraction, but needed for struct fields: Vec::new(), seen: HashSet::new(), } @@ -88,18 +101,42 @@ impl<'ast, 'cfg> FieldExtractor<'ast, 'cfg> { } } - /// Check if the base expression is `ctx.accounts`. + /// Check if the base expression is `.accounts` (e.g., `ctx.accounts`, `context.accounts`). + /// Uses the default context name "ctx". pub fn is_ctx_accounts(base: &Expr) -> bool { + Self::is_ctx_accounts_with_name(base, "ctx") + } + + /// Check if the base expression is `.accounts` with a custom context name. + pub fn is_ctx_accounts_with_name(base: &Expr, ctx_name: &str) -> bool { if let Expr::Field(nested) = base { if let Member::Named(member) = &nested.member { - return member == "accounts" && Self::is_path_ident(&nested.base, "ctx"); + return member == "accounts" && Self::is_path_ident(&nested.base, ctx_name); } } false } + /// Check if the base expression matches the `.accounts` pattern. + /// This is flexible and accepts any identifier before `.accounts` (ctx, context, anchor_ctx, etc.). + /// Returns the identifier name if matched. + pub fn is_any_ctx_accounts(base: &Expr) -> Option { + if let Expr::Field(nested) = base { + if let Member::Named(member) = &nested.member { + if member == "accounts" { + if let Expr::Path(path) = &*nested.base { + if let Some(ident) = path.path.get_ident() { + return Some(ident.to_string()); + } + } + } + } + } + None + } + /// Check if an expression is a path with the given identifier. - fn is_path_ident(expr: &Expr, ident: &str) -> bool { + pub fn is_path_ident(expr: &Expr, ident: &str) -> bool { matches!(expr, Expr::Path(p) if p.path.is_ident(ident)) } } @@ -107,15 +144,15 @@ impl<'ast, 'cfg> FieldExtractor<'ast, 'cfg> { 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) { + // Check for ctx.accounts.field pattern (using configured ctx_name) + if self.extract_ctx && Self::is_ctx_accounts_with_name(&node.base, self.ctx_name) { 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") { + // Check for ctx.field pattern (direct access, using configured ctx_name) + if self.extract_ctx && Self::is_path_ident(&node.base, self.ctx_name) { self.try_add(field_name); return; } @@ -182,10 +219,11 @@ fn classify_seed_expr(expr: &syn::Expr) -> syn::Result { } /// Classify a field expression (e.g., ctx.field, data.field). +/// Accepts any context name (ctx, context, anchor_ctx, etc.) for `.accounts.field` patterns. 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) { + // Check for .accounts.field pattern (ctx.accounts.field, context.accounts.field, etc.) + if FieldExtractor::is_any_ctx_accounts(&field_expr.base).is_some() { return Ok(ClientSeedInfo::CtxField { field: field_name.clone(), method: None, @@ -201,12 +239,12 @@ fn classify_field_expr(field_expr: &syn::ExprField) -> syn::Result syn::Result 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) { + // Check for .accounts.field.method() pattern + if FieldExtractor::is_any_ctx_accounts(&field_expr.base).is_some() { return Ok(ClientSeedInfo::CtxField { field: field_name.clone(), method: Some(method_call.method.clone()), @@ -244,12 +283,11 @@ fn classify_method_call(method_call: &syn::ExprMethodCall) -> syn::Result( - ctx: Context<'_, '_, '_, 'info, CreateTwoMints<'info>>, + context: Context<'_, '_, '_, 'info, CreateTwoMints<'info>>, params: CreateTwoMintsParams, ) -> Result<()> { // Both mints are created by the RentFree macro in pre_init @@ -361,9 +362,10 @@ pub mod csdk_anchor_full_derived_test { /// Test instruction that creates 3 mints in a single transaction. /// Tests the multi-mint support in the RentFree macro scales beyond 2. + /// Also tests dynamic context name detection using "anchor_ctx" instead of "ctx". #[allow(unused_variables)] pub fn create_three_mints<'info>( - ctx: Context<'_, '_, '_, 'info, CreateThreeMints<'info>>, + anchor_ctx: Context<'_, '_, '_, 'info, CreateThreeMints<'info>>, params: CreateThreeMintsParams, ) -> Result<()> { // All 3 mints are created by the RentFree macro in pre_init @@ -373,9 +375,10 @@ pub mod csdk_anchor_full_derived_test { /// Test instruction that creates a mint with metadata. /// Tests the metadata support in the RentFree macro. + /// Also tests dynamic context name detection using "c" (single letter) instead of "ctx". #[allow(unused_variables)] pub fn create_mint_with_metadata<'info>( - ctx: Context<'_, '_, '_, 'info, CreateMintWithMetadata<'info>>, + c: Context<'_, '_, '_, 'info, CreateMintWithMetadata<'info>>, params: CreateMintWithMetadataParams, ) -> Result<()> { // Mint with metadata is created by the RentFree macro in pre_init @@ -1272,9 +1275,10 @@ pub mod csdk_anchor_full_derived_test { /// D10: Single vault with #[light_account(init, token, ...)] /// This tests automatic code generation for token account creation. /// The macro should generate CreateTokenAccountCpi in LightFinalize. + /// Also tests dynamic context name detection using "my_ctx" instead of "ctx". #[allow(unused_variables)] pub fn d10_single_vault<'info>( - ctx: Context<'_, '_, '_, 'info, D10SingleVault<'info>>, + my_ctx: Context<'_, '_, '_, 'info, D10SingleVault<'info>>, params: D10SingleVaultParams, ) -> Result<()> { // Token account creation is handled by the LightFinalize trait implementation @@ -1286,9 +1290,10 @@ pub mod csdk_anchor_full_derived_test { /// D10: Single ATA with #[light_account(init, associated_token, ...)] /// This tests automatic code generation for ATA creation. /// The macro should generate create_associated_token_account_idempotent in LightFinalize. + /// Also tests dynamic context name detection using "cx" instead of "ctx". #[allow(unused_variables)] pub fn d10_single_ata<'info>( - ctx: Context<'_, '_, '_, 'info, D10SingleAta<'info>>, + cx: Context<'_, '_, '_, 'info, D10SingleAta<'info>>, params: D10SingleAtaParams, ) -> Result<()> { // ATA creation is handled by the LightFinalize trait implementation From c7051d1b671b8a587b93b9a6cc8049c1c5861f1e Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 20 Jan 2026 01:38:21 +0000 Subject: [PATCH 4/4] chore: generalize instruction data parsing --- .../src/light_pdas/account/seed_extraction.rs | 382 +++++++++-- .../src/light_pdas/program/instructions.rs | 8 +- .../instructions/d9_seeds/instruction_data.rs | 362 +++++++++++ .../src/instructions/d9_seeds/mod.rs | 2 + .../csdk-anchor-full-derived-test/src/lib.rs | 115 ++++ .../tests/basic_test.rs | 606 ++++++++++++++++++ 6 files changed, 1430 insertions(+), 45 deletions(-) create mode 100644 sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/instruction_data.rs diff --git a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs index 51122d26ee..e809df6e59 100644 --- a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs +++ b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs @@ -3,6 +3,8 @@ //! This module extracts PDA seeds from Anchor's attribute syntax and classifies them //! into the categories needed for compression: literals, ctx fields, data fields, etc. +use std::collections::HashSet; + use syn::{Expr, Ident, ItemStruct, Type}; use crate::{ @@ -10,6 +12,74 @@ use crate::{ utils::snake_to_camel_case, }; +/// Set of instruction argument names for Format 2 detection. +/// +/// Anchor supports two formats for `#[instruction(...)]`: +/// - Format 1: `#[instruction(params: SomeStruct)]` - users write `params.field` +/// - Format 2: `#[instruction(owner: Pubkey, amount: u64)]` - users write bare `owner` +/// +/// This struct holds the names from Format 2 so we can recognize them in seed expressions. +#[derive(Clone, Debug, Default)] +pub struct InstructionArgSet { + /// Names of instruction args (e.g., {"owner", "amount", "bump"}) + pub names: HashSet, +} + +impl InstructionArgSet { + /// Create an empty arg set (used when no #[instruction] attribute present) + pub fn empty() -> Self { + Self { + names: HashSet::new(), + } + } + + /// Create from a list of argument names + pub fn from_names(names: impl IntoIterator) -> Self { + Self { + names: names.into_iter().collect(), + } + } + + /// Check if a name is a known instruction argument + pub fn contains(&self, name: &str) -> bool { + self.names.contains(name) + } +} + +/// Parse #[instruction(...)] attribute from a struct's attributes and return InstructionArgSet +pub fn parse_instruction_arg_names(attrs: &[syn::Attribute]) -> syn::Result { + for attr in attrs { + if attr.path().is_ident("instruction") { + let content = attr.parse_args_with(|input: syn::parse::ParseStream| { + let args: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::parse_terminated(input)?; + Ok(args + .into_iter() + .map(|a| a.name.to_string()) + .collect::>()) + })?; + return Ok(InstructionArgSet::from_names(content)); + } + } + Ok(InstructionArgSet::empty()) +} + +/// Helper struct for parsing instruction args +struct InstructionArg { + name: syn::Ident, + #[allow(dead_code)] + ty: syn::Type, +} + +impl syn::parse::Parse for InstructionArg { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let name = input.parse()?; + input.parse::()?; + let ty = input.parse()?; + Ok(Self { name, ty }) + } +} + /// Classified seed element from Anchor's seeds array #[derive(Clone, Debug)] pub enum ClassifiedSeed { @@ -76,6 +146,7 @@ pub struct ExtractedAccountsInfo { /// Extract rentfree field info from an Accounts struct pub fn extract_from_accounts_struct( item: &ItemStruct, + instruction_args: &InstructionArgSet, ) -> syn::Result> { let fields = match &item.fields { syn::Fields::Named(named) => &named.named, @@ -101,7 +172,7 @@ pub fn extract_from_accounts_struct( } // Check for #[light_account(token, ...)] attribute - let token_attr = extract_light_token_attr(&field.attrs); + let token_attr = extract_light_token_attr(&field.attrs, instruction_args); if has_light_account_pda { // Extract inner type from Account<'info, T> or Box> @@ -117,7 +188,7 @@ pub fn extract_from_accounts_struct( }; // Extract seeds from #[account(seeds = [...])] - let seeds = extract_anchor_seeds(&field.attrs)?; + let seeds = extract_anchor_seeds(&field.attrs, instruction_args)?; // Derive variant name from field name: snake_case -> CamelCase let variant_name = { @@ -132,7 +203,7 @@ pub fn extract_from_accounts_struct( }); } else if let Some(token_attr) = token_attr { // Token field - derive variant name from field name if not provided - let seeds = extract_anchor_seeds(&field.attrs)?; + let seeds = extract_anchor_seeds(&field.attrs, instruction_args)?; // Derive variant name: snake_case field -> CamelCase variant let variant_name = token_attr.variant_name.unwrap_or_else(|| { @@ -180,7 +251,9 @@ pub fn extract_from_accounts_struct( 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 let Ok(auth_seeds) = + extract_anchor_seeds(&auth_field_info.attrs, instruction_args) + { if !auth_seeds.is_empty() { token.authority_seeds = Some(auth_seeds); } @@ -241,7 +314,10 @@ struct LightTokenAttr { /// Extract #[light_account(token, authority = [...])] attribute /// Variant name is derived from field name, not specified in attribute -fn extract_light_token_attr(attrs: &[syn::Attribute]) -> Option { +fn extract_light_token_attr( + attrs: &[syn::Attribute], + instruction_args: &InstructionArgSet, +) -> Option { for attr in attrs { if attr.path().is_ident("light_account") { let tokens = match &attr.meta { @@ -257,7 +333,7 @@ fn extract_light_token_attr(attrs: &[syn::Attribute]) -> Option if has_token { // Parse authority = [...] if present - if let Ok(parsed) = parse_light_token_list(&tokens) { + if let Ok(parsed) = parse_light_token_list(&tokens, instruction_args) { return Some(parsed); } return Some(LightTokenAttr { @@ -271,10 +347,15 @@ fn extract_light_token_attr(attrs: &[syn::Attribute]) -> Option } /// Parse light_account(token, authority = [...]) content -fn parse_light_token_list(tokens: &proc_macro2::TokenStream) -> syn::Result { +fn parse_light_token_list( + tokens: &proc_macro2::TokenStream, + instruction_args: &InstructionArgSet, +) -> syn::Result { use syn::parse::Parser; - let parser = |input: syn::parse::ParseStream| -> syn::Result { + // Capture instruction_args for use in closure + let instruction_args = instruction_args.clone(); + let parser = move |input: syn::parse::ParseStream| -> syn::Result { let mut authority_seeds = None; // Parse comma-separated items looking for "token" and "authority = [...]" @@ -290,7 +371,7 @@ fn parse_light_token_list(tokens: &proc_macro2::TokenStream) -> syn::Result Option<(bool, Type)> { } /// Extract seeds from #[account(seeds = [...], bump)] attribute -pub fn extract_anchor_seeds(attrs: &[syn::Attribute]) -> syn::Result> { +pub fn extract_anchor_seeds( + attrs: &[syn::Attribute], + instruction_args: &InstructionArgSet, +) -> syn::Result> { for attr in attrs { if !attr.path().is_ident("account") { continue; @@ -399,7 +483,7 @@ pub fn extract_anchor_seeds(attrs: &[syn::Attribute]) -> syn::Result syn::Result> { +fn classify_seeds_array( + expr: &Expr, + instruction_args: &InstructionArgSet, +) -> syn::Result> { let array = match expr { Expr::Array(arr) => arr, Expr::Reference(r) => { @@ -455,14 +542,17 @@ fn classify_seeds_array(expr: &Expr) -> syn::Result> { let mut seeds = Vec::new(); for elem in &array.elems { - seeds.push(classify_seed_expr(elem)?); + seeds.push(classify_seed_expr(elem, instruction_args)?); } Ok(seeds) } /// Classify a single seed expression -pub fn classify_seed_expr(expr: &Expr) -> syn::Result { +pub fn classify_seed_expr( + expr: &Expr, + instruction_args: &InstructionArgSet, +) -> syn::Result { match expr { // b"literal" Expr::Lit(lit) => { @@ -478,13 +568,26 @@ pub fn classify_seed_expr(expr: &Expr) -> syn::Result { )) } - // CONSTANT (all uppercase path) + // CONSTANT (all uppercase path) or bare instruction arg Expr::Path(path) => { if let Some(ident) = path.path.get_ident() { - if is_constant_identifier(&ident.to_string()) { + let name = ident.to_string(); + + // Check uppercase constant first + if is_constant_identifier(&name) { return Ok(ClassifiedSeed::Constant(path.path.clone())); } - // Otherwise it's a variable reference - treat as ctx account + + // Check if this is a bare instruction arg (Format 2) + // e.g., #[instruction(owner: Pubkey)] -> seeds = [owner.as_ref()] + if instruction_args.contains(&name) { + return Ok(ClassifiedSeed::DataField { + field_name: ident.clone(), + conversion: None, + }); + } + + // Otherwise treat as ctx account reference return Ok(ClassifiedSeed::CtxAccount(ident.clone())); } // Multi-segment path is a constant @@ -492,16 +595,16 @@ pub fn classify_seed_expr(expr: &Expr) -> syn::Result { } // method_call.as_ref() - most common case - Expr::MethodCall(mc) => classify_method_call(mc), + Expr::MethodCall(mc) => classify_method_call(mc, instruction_args), // Reference like &account.key() - Expr::Reference(r) => classify_seed_expr(&r.expr), + Expr::Reference(r) => classify_seed_expr(&r.expr, instruction_args), // Field access like params.owner or params.nested.owner - direct field reference Expr::Field(field) => { if let syn::Member::Named(field_name) = &field.member { - // Check if root of the expression is "params" - if is_params_rooted(&field.base) { + // Check if root of the expression is an instruction arg + if is_instruction_arg_rooted(&field.base, instruction_args) { return Ok(ClassifiedSeed::DataField { field_name: field_name.clone(), conversion: None, @@ -540,7 +643,7 @@ pub fn classify_seed_expr(expr: &Expr) -> syn::Result { // Index expression - handles two cases: // 1. b"literal"[..] - converts [u8; N] to &[u8] - // 2. params.arrays[2] - array indexing on params field + // 2. params.arrays[2] - array indexing on instruction arg field Expr::Index(idx) => { // Case 1: Check if the index is a full range (..) on byte literal if let Expr::Range(range) = &*idx.index { @@ -554,8 +657,8 @@ pub fn classify_seed_expr(expr: &Expr) -> syn::Result { } } - // Case 2: Array indexing on params field like params.arrays[2] - if is_params_rooted(&idx.expr) { + // Case 2: Array indexing on instruction arg field like params.arrays[2] + if is_instruction_arg_rooted(&idx.expr, instruction_args) { if let Some(field_name) = extract_terminal_field(&idx.expr) { return Ok(ClassifiedSeed::DataField { field_name, @@ -578,28 +681,46 @@ pub fn classify_seed_expr(expr: &Expr) -> syn::Result { } /// Classify a method call expression like account.key().as_ref() -fn classify_method_call(mc: &syn::ExprMethodCall) -> syn::Result { +fn classify_method_call( + mc: &syn::ExprMethodCall, + instruction_args: &InstructionArgSet, +) -> syn::Result { // Unwrap .as_ref(), .as_bytes(), or .as_slice() at the end - these are terminal conversions if mc.method == "as_ref" || mc.method == "as_bytes" || mc.method == "as_slice" { - return classify_seed_expr(&mc.receiver); + return classify_seed_expr(&mc.receiver, instruction_args); } - // Handle params.field.to_le_bytes() or params.nested.field.to_le_bytes() - if (mc.method == "to_le_bytes" || mc.method == "to_be_bytes") && is_params_rooted(&mc.receiver) - { - if let Some(field_name) = extract_terminal_field(&mc.receiver) { - return Ok(ClassifiedSeed::DataField { - field_name, - conversion: Some(mc.method.clone()), - }); + // Handle instruction_arg.field.to_le_bytes() or instruction_arg.nested.field.to_le_bytes() + // Also handle bare instruction arg: amount.to_le_bytes() where amount is a direct instruction arg + if mc.method == "to_le_bytes" || mc.method == "to_be_bytes" { + // Check for bare instruction arg like amount.to_le_bytes() + if let Expr::Path(path) = &*mc.receiver { + if let Some(ident) = path.path.get_ident() { + if instruction_args.contains(&ident.to_string()) { + return Ok(ClassifiedSeed::DataField { + field_name: ident.clone(), + conversion: Some(mc.method.clone()), + }); + } + } + } + + // Check for field access on instruction arg + if is_instruction_arg_rooted(&mc.receiver, instruction_args) { + if let Some(field_name) = extract_terminal_field(&mc.receiver) { + return Ok(ClassifiedSeed::DataField { + field_name, + conversion: Some(mc.method.clone()), + }); + } } } // Handle account.key() if mc.method == "key" { if let Some(ident) = extract_terminal_ident(&mc.receiver, false) { - // Check if it's rooted in params - if is_params_rooted(&mc.receiver) { + // Check if it's rooted in an instruction arg + if is_instruction_arg_rooted(&mc.receiver, instruction_args) { if let Some(field_name) = extract_terminal_field(&mc.receiver) { return Ok(ClassifiedSeed::DataField { field_name, @@ -611,8 +732,8 @@ fn classify_method_call(mc: &syn::ExprMethodCall) -> syn::Result } } - // params.field or params.nested.field - check for params-rooted access - if is_params_rooted(&mc.receiver) { + // instruction_arg.field or instruction_arg.nested.field - check for instruction-arg-rooted access + if is_instruction_arg_rooted(&mc.receiver, instruction_args) { if let Some(field_name) = extract_terminal_field(&mc.receiver) { return Ok(ClassifiedSeed::DataField { field_name, @@ -627,17 +748,24 @@ fn classify_method_call(mc: &syn::ExprMethodCall) -> syn::Result )) } -/// Check if an expression is rooted in "params" (handles nested access like params.nested.field) -fn is_params_rooted(expr: &Expr) -> bool { +/// Check if an expression is rooted in an instruction argument. +/// Works with ANY instruction arg name, not just "params". +fn is_instruction_arg_rooted(expr: &Expr, instruction_args: &InstructionArgSet) -> bool { match expr { - Expr::Path(path) => path.path.get_ident().is_some_and(|ident| ident == "params"), + Expr::Path(path) => { + if let Some(ident) = path.path.get_ident() { + instruction_args.contains(&ident.to_string()) + } else { + false + } + } Expr::Field(field) => { // Recursively check the base - is_params_rooted(&field.base) + is_instruction_arg_rooted(&field.base, instruction_args) } Expr::Index(idx) => { // For array indexing like params.arrays[2], check the base - is_params_rooted(&idx.expr) + is_instruction_arg_rooted(&idx.expr, instruction_args) } _ => false, } @@ -742,3 +870,171 @@ fn extract_data_field_from_expr(expr: &syn::Expr) -> Option<(Ident, bool)> { _ => None, } } + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::*; + + fn make_instruction_args(names: &[&str]) -> InstructionArgSet { + InstructionArgSet::from_names(names.iter().map(|s| s.to_string())) + } + + #[test] + fn test_bare_pubkey_instruction_arg() { + let args = make_instruction_args(&["owner", "amount"]); + let expr: syn::Expr = parse_quote!(owner); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); + } + + #[test] + fn test_bare_primitive_with_to_le_bytes() { + let args = make_instruction_args(&["amount"]); + let expr: syn::Expr = parse_quote!(amount.to_le_bytes().as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!( + result, + ClassifiedSeed::DataField { + field_name, + conversion: Some(conv) + } if field_name == "amount" && conv == "to_le_bytes" + )); + } + + #[test] + fn test_custom_struct_param_name() { + let args = make_instruction_args(&["input"]); + let expr: syn::Expr = parse_quote!(input.owner.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); + } + + #[test] + fn test_nested_field_access() { + let args = make_instruction_args(&["data"]); + let expr: syn::Expr = parse_quote!(data.inner.key.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "key") + ); + } + + #[test] + fn test_context_account_not_confused_with_arg() { + let args = make_instruction_args(&["owner"]); // "authority" is NOT an arg + let expr: syn::Expr = parse_quote!(authority.key().as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!( + result, + ClassifiedSeed::CtxAccount(ident) if ident == "authority" + )); + } + + #[test] + fn test_empty_instruction_args() { + let args = InstructionArgSet::empty(); + let expr: syn::Expr = parse_quote!(owner); + let result = classify_seed_expr(&expr, &args).unwrap(); + // Without instruction args, bare ident treated as ctx account + assert!(matches!(result, ClassifiedSeed::CtxAccount(_))); + } + + #[test] + fn test_literal_seed() { + let args = InstructionArgSet::empty(); + let expr: syn::Expr = parse_quote!(b"seed"); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!(result, ClassifiedSeed::Literal(bytes) if bytes == b"seed")); + } + + #[test] + fn test_constant_seed() { + let args = InstructionArgSet::empty(); + let expr: syn::Expr = parse_quote!(SEED_PREFIX); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!(result, ClassifiedSeed::Constant(_))); + } + + #[test] + fn test_standard_params_field_access() { + // Traditional format: #[instruction(params: CreateParams)] + let args = make_instruction_args(&["params"]); + let expr: syn::Expr = parse_quote!(params.owner.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); + } + + #[test] + fn test_args_naming_format() { + // Alternative naming: #[instruction(args: MyArgs)] + let args = make_instruction_args(&["args"]); + let expr: syn::Expr = parse_quote!(args.key.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "key") + ); + } + + #[test] + fn test_data_naming_format() { + // Alternative naming: #[instruction(data: DataInput)] + let args = make_instruction_args(&["data"]); + let expr: syn::Expr = parse_quote!(data.value.to_le_bytes().as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!( + result, + ClassifiedSeed::DataField { + field_name, + conversion: Some(conv) + } if field_name == "value" && conv == "to_le_bytes" + )); + } + + #[test] + fn test_format2_multiple_params() { + // Format 2: #[instruction(owner: Pubkey, amount: u64)] + let args = make_instruction_args(&["owner", "amount"]); + + let expr1: syn::Expr = parse_quote!(owner.as_ref()); + let result1 = classify_seed_expr(&expr1, &args).unwrap(); + assert!( + matches!(result1, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); + + let expr2: syn::Expr = parse_quote!(amount.to_le_bytes().as_ref()); + let result2 = classify_seed_expr(&expr2, &args).unwrap(); + assert!(matches!( + result2, + ClassifiedSeed::DataField { + field_name, + conversion: Some(_) + } if field_name == "amount" + )); + } + + #[test] + fn test_parse_instruction_arg_names() { + // Test that we can parse instruction attributes + let attrs: Vec = vec![parse_quote!(#[instruction(owner: Pubkey)])]; + let args = parse_instruction_arg_names(&attrs).unwrap(); + assert!(args.contains("owner")); + } + + #[test] + fn test_parse_instruction_arg_names_multiple() { + let attrs: Vec = + vec![parse_quote!(#[instruction(owner: Pubkey, amount: u64, flag: bool)])]; + let args = parse_instruction_arg_names(&attrs).unwrap(); + assert!(args.contains("owner")); + assert!(args.contains("amount")); + assert!(args.contains("flag")); + } +} diff --git a/sdk-libs/macros/src/light_pdas/program/instructions.rs b/sdk-libs/macros/src/light_pdas/program/instructions.rs index c7e9ff538a..f5f7b4bfae 100644 --- a/sdk-libs/macros/src/light_pdas/program/instructions.rs +++ b/sdk-libs/macros/src/light_pdas/program/instructions.rs @@ -487,7 +487,8 @@ fn codegen( pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result { use super::crate_context::CrateContext; use crate::light_pdas::account::seed_extraction::{ - extract_from_accounts_struct, get_data_fields, ExtractedSeedSpec, ExtractedTokenSpec, + extract_from_accounts_struct, get_data_fields, parse_instruction_arg_names, + ExtractedSeedSpec, ExtractedTokenSpec, }; if module.content.is_none() { @@ -503,7 +504,10 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"instr_single", params.owner.as_ref()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 2: Params with u64 field requiring to_le_bytes +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9U64Params { + pub create_accounts_proof: CreateAccountsProof, + pub amount: u64, +} + +/// Tests params.amount.to_le_bytes() pattern +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9U64Params)] +pub struct D9InstrU64<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"instr_u64_", params.amount.to_le_bytes().as_ref()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 3: Multiple data fields in seeds +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9MultiFieldParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, + pub amount: u64, +} + +/// Tests multiple params fields: owner + amount +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9MultiFieldParams)] +pub struct D9InstrMultiField<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"instr_multi", params.owner.as_ref(), ¶ms.amount.to_le_bytes()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 4: Mixed params and ctx account in seeds +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9MixedCtxParams { + pub create_accounts_proof: CreateAccountsProof, + pub data_key: Pubkey, +} + +/// Tests mixing params.data_key with ctx.authority +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9MixedCtxParams)] +pub struct D9InstrMixedCtx<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub authority: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"instr_mixed", authority.key().as_ref(), params.data_key.as_ref()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 5: Three data fields +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9TripleParams { + pub create_accounts_proof: CreateAccountsProof, + pub key_a: Pubkey, + pub value_b: u64, + pub flag_c: u8, +} + +/// Tests three params fields with different types +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9TripleParams)] +pub struct D9InstrTriple<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"instr_triple", params.key_a.as_ref(), params.value_b.to_le_bytes().as_ref()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 6: to_be_bytes conversion +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9BigEndianParams { + pub create_accounts_proof: CreateAccountsProof, + pub value: u64, +} + +/// Tests params.value.to_be_bytes() (big endian) +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9BigEndianParams)] +pub struct D9InstrBigEndian<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"instr_be", ¶ms.value.to_be_bytes()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 7: Multiple u64 fields +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9MultiU64Params { + pub create_accounts_proof: CreateAccountsProof, + pub id: u64, + pub counter: u64, +} + +/// Tests multiple u64 fields with to_le_bytes +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9MultiU64Params)] +pub struct D9InstrMultiU64<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"multi_u64", params.id.to_le_bytes().as_ref(), params.counter.to_le_bytes().as_ref()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 8: Pubkey with as_ref chained +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9ChainedAsRefParams { + pub create_accounts_proof: CreateAccountsProof, + pub key: Pubkey, +} + +/// Tests params.key.as_ref() explicitly chained +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9ChainedAsRefParams)] +pub struct D9InstrChainedAsRef<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [b"instr_chain", params.key.as_ref()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 9: Constant mixed with params +// ============================================================================ + +/// Local seed constant +pub const D9_INSTR_SEED: &[u8] = b"d9_instr_const"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9ConstMixedParams { + pub create_accounts_proof: CreateAccountsProof, + pub owner: Pubkey, +} + +/// Tests constant + params.owner in seeds +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9ConstMixedParams)] +pub struct D9InstrConstMixed<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [D9_INSTR_SEED, params.owner.as_ref()], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================ +// Test 10: Complex mixed - literal + constant + ctx + params +// ============================================================================ + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct D9ComplexMixedParams { + pub create_accounts_proof: CreateAccountsProof, + pub data_owner: Pubkey, + pub data_amount: u64, +} + +/// Tests complex mix: literal + authority + params.data_owner + params.data_amount +#[derive(Accounts, LightAccounts)] +#[instruction(params: D9ComplexMixedParams)] +pub struct D9InstrComplexMixed<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub authority: Signer<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + space = 8 + SinglePubkeyRecord::INIT_SPACE, + seeds = [ + b"complex", + authority.key().as_ref(), + params.data_owner.as_ref(), + ¶ms.data_amount.to_le_bytes() + ], + bump, + )] + #[light_account(init)] + pub record: Account<'info, SinglePubkeyRecord>, + + pub system_program: Program<'info, System>, +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mod.rs index 14b8852a71..d9fa9792bc 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d9_seeds/mod.rs @@ -25,6 +25,7 @@ mod ctx_account; pub mod edge_cases; pub mod external_paths; mod function_call; +pub mod instruction_data; mod literal; pub mod method_chains; mod mixed; @@ -42,6 +43,7 @@ pub use ctx_account::*; pub use edge_cases::*; pub use external_paths::*; pub use function_call::*; +pub use instruction_data::*; pub use literal::*; pub use method_chains::*; pub use mixed::*; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index e3ff150ae4..d31a32fbb4 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -113,6 +113,8 @@ pub mod csdk_anchor_full_derived_test { D9AssocConstMethod, D9AssocConstMethodParams, D9AssocConstParams, + // Instruction data tests (various params struct patterns) + D9BigEndianParams, D9BumpConstant, D9BumpConstantParams, D9BumpCtx, @@ -126,6 +128,7 @@ pub mod csdk_anchor_full_derived_test { D9BumpParamParams, D9BumpQualified, D9BumpQualifiedParams, + D9ChainedAsRefParams, D9ComplexAllQualified, D9ComplexAllQualifiedParams, D9ComplexFive, @@ -136,6 +139,7 @@ pub mod csdk_anchor_full_derived_test { D9ComplexFuncParams, D9ComplexIdFunc, D9ComplexIdFuncParams, + D9ComplexMixedParams, D9ComplexProgramId, D9ComplexProgramIdParams, D9ComplexQualifiedMix, @@ -149,6 +153,7 @@ pub mod csdk_anchor_full_derived_test { D9ConstFnGeneric, D9ConstFnGenericParams, D9ConstFnParams, + D9ConstMixedParams, D9Constant, D9ConstantParams, D9CtxAccount, @@ -189,6 +194,16 @@ pub mod csdk_anchor_full_derived_test { D9FullyQualifiedTraitParams, D9FunctionCall, D9FunctionCallParams, + D9InstrBigEndian, + D9InstrChainedAsRef, + D9InstrComplexMixed, + D9InstrConstMixed, + D9InstrMixedCtx, + D9InstrMultiField, + D9InstrMultiU64, + D9InstrSinglePubkey, + D9InstrTriple, + D9InstrU64, D9Literal, D9LiteralParams, D9MethodAsBytes, @@ -205,9 +220,12 @@ pub mod csdk_anchor_full_derived_test { D9MethodToLeBytes, D9MethodToLeBytesParams, D9Mixed, + D9MixedCtxParams, D9MixedParams, D9MultiAssocConst, D9MultiAssocConstParams, + D9MultiFieldParams, + D9MultiU64Params, D9NestedArrayField, D9NestedArrayFieldParams, D9NestedBytes, @@ -236,10 +254,13 @@ pub mod csdk_anchor_full_derived_test { D9QualifiedMixedParams, D9QualifiedSelf, D9QualifiedSelfParams, + D9SinglePubkeyParams, D9Static, D9StaticParams, D9TraitAssocConst, D9TraitAssocConstParams, + D9TripleParams, + D9U64Params, }, instruction_accounts::{ CreateMintWithMetadata, CreateMintWithMetadataParams, CreatePdasAndMintAuto, @@ -1194,6 +1215,100 @@ pub mod csdk_anchor_full_derived_test { Ok(()) } + // ========================================================================= + // D9 Instruction Data Tests (various params struct patterns) + // ========================================================================= + + /// D9: Standard params with single Pubkey field + pub fn d9_instr_single_pubkey<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrSinglePubkey<'info>>, + params: D9SinglePubkeyParams, + ) -> Result<()> { + ctx.accounts.record.owner = params.owner; + Ok(()) + } + + /// D9: Params with u64 field requiring to_le_bytes + pub fn d9_instr_u64<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrU64<'info>>, + _params: D9U64Params, + ) -> Result<()> { + ctx.accounts.record.owner = ctx.accounts.fee_payer.key(); + Ok(()) + } + + /// D9: Multiple data fields in seeds (owner + amount) + pub fn d9_instr_multi_field<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrMultiField<'info>>, + params: D9MultiFieldParams, + ) -> Result<()> { + ctx.accounts.record.owner = params.owner; + Ok(()) + } + + /// D9: Mixed params and ctx account in seeds + pub fn d9_instr_mixed_ctx<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrMixedCtx<'info>>, + params: D9MixedCtxParams, + ) -> Result<()> { + ctx.accounts.record.owner = params.data_key; + Ok(()) + } + + /// D9: Three data fields with different types + pub fn d9_instr_triple<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrTriple<'info>>, + params: D9TripleParams, + ) -> Result<()> { + ctx.accounts.record.owner = params.key_a; + Ok(()) + } + + /// D9: to_be_bytes conversion (big endian) + pub fn d9_instr_big_endian<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrBigEndian<'info>>, + _params: D9BigEndianParams, + ) -> Result<()> { + ctx.accounts.record.owner = ctx.accounts.fee_payer.key(); + Ok(()) + } + + /// D9: Multiple u64 fields with to_le_bytes + pub fn d9_instr_multi_u64<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrMultiU64<'info>>, + _params: D9MultiU64Params, + ) -> Result<()> { + ctx.accounts.record.owner = ctx.accounts.fee_payer.key(); + Ok(()) + } + + /// D9: Pubkey with as_ref chained + pub fn d9_instr_chained_as_ref<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrChainedAsRef<'info>>, + params: D9ChainedAsRefParams, + ) -> Result<()> { + ctx.accounts.record.owner = params.key; + Ok(()) + } + + /// D9: Constant mixed with params + pub fn d9_instr_const_mixed<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrConstMixed<'info>>, + params: D9ConstMixedParams, + ) -> Result<()> { + ctx.accounts.record.owner = params.owner; + Ok(()) + } + + /// D9: Complex mixed - literal + constant + ctx + params + pub fn d9_instr_complex_mixed<'info>( + ctx: Context<'_, '_, '_, 'info, D9InstrComplexMixed<'info>>, + params: D9ComplexMixedParams, + ) -> Result<()> { + ctx.accounts.record.owner = params.data_owner; + Ok(()) + } + // ========================================================================= // D5 Additional Markers Tests // ========================================================================= diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index db622f1dc0..75e3914316 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -856,3 +856,609 @@ async fn test_create_multi_mints() { "Mint C authority should be fee_payer" ); } + +/// Helper function to set up test context for D9 instruction data tests. +/// Returns (rpc, payer, program_id, config_pda). +async fn setup_d9_test_context() -> (LightProgramTest, Keypair, Pubkey, Pubkey) { + use light_token_sdk::token::RENT_SPONSOR; + + let program_id = csdk_anchor_full_derived_test::ID; + let mut config = ProgramTestConfig::new_v2( + true, + Some(vec![("csdk_anchor_full_derived_test", program_id)]), + ); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + (rpc, payer, program_id, config_pda) +} + +/// Test D9InstrSinglePubkey - seeds = [b"instr_single", params.owner.as_ref()] +#[tokio::test] +async fn test_d9_instr_single_pubkey() { + use csdk_anchor_full_derived_test::D9SinglePubkeyParams; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let owner = Keypair::new().pubkey(); + let (record_pda, _) = + Pubkey::find_program_address(&[b"instr_single", owner.as_ref()], &program_id); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrSinglePubkey { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrSinglePubkey { + params: D9SinglePubkeyParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrSinglePubkey should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrU64 - seeds = [b"instr_u64_", params.amount.to_le_bytes().as_ref()] +#[tokio::test] +async fn test_d9_instr_u64() { + use csdk_anchor_full_derived_test::D9U64Params; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let amount = 12345u64; + let (record_pda, _) = + Pubkey::find_program_address(&[b"instr_u64_", amount.to_le_bytes().as_ref()], &program_id); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrU64 { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrU64 { + _params: D9U64Params { + create_accounts_proof: proof_result.create_accounts_proof, + amount, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrU64 should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrMultiField - seeds = [b"instr_multi", params.owner.as_ref(), ¶ms.amount.to_le_bytes()] +#[tokio::test] +async fn test_d9_instr_multi_field() { + use csdk_anchor_full_derived_test::D9MultiFieldParams; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let owner = Keypair::new().pubkey(); + let amount = 99999u64; + let (record_pda, _) = Pubkey::find_program_address( + &[b"instr_multi", owner.as_ref(), &amount.to_le_bytes()], + &program_id, + ); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrMultiField { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrMultiField { + params: D9MultiFieldParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + amount, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrMultiField should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrMixedCtx - seeds = [b"instr_mixed", authority.key().as_ref(), params.data_key.as_ref()] +#[tokio::test] +async fn test_d9_instr_mixed_ctx() { + use csdk_anchor_full_derived_test::D9MixedCtxParams; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + let authority = Keypair::new(); + + let data_key = Keypair::new().pubkey(); + let (record_pda, _) = Pubkey::find_program_address( + &[ + b"instr_mixed", + authority.pubkey().as_ref(), + data_key.as_ref(), + ], + &program_id, + ); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrMixedCtx { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrMixedCtx { + params: D9MixedCtxParams { + create_accounts_proof: proof_result.create_accounts_proof, + data_key, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("D9InstrMixedCtx should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrTriple - seeds = [b"instr_triple", params.key_a.as_ref(), params.value_b.to_le_bytes().as_ref()] +#[tokio::test] +async fn test_d9_instr_triple() { + use csdk_anchor_full_derived_test::D9TripleParams; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let key_a = Keypair::new().pubkey(); + let value_b = 777u64; + let flag_c = 42u8; + let (record_pda, _) = Pubkey::find_program_address( + &[ + b"instr_triple", + key_a.as_ref(), + value_b.to_le_bytes().as_ref(), + ], + &program_id, + ); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrTriple { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrTriple { + params: D9TripleParams { + create_accounts_proof: proof_result.create_accounts_proof, + key_a, + value_b, + flag_c, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrTriple should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrBigEndian - seeds = [b"instr_be", ¶ms.value.to_be_bytes()] +#[tokio::test] +async fn test_d9_instr_big_endian() { + use csdk_anchor_full_derived_test::D9BigEndianParams; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let value = 0xDEADBEEFu64; + let (record_pda, _) = + Pubkey::find_program_address(&[b"instr_be", &value.to_be_bytes()], &program_id); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrBigEndian { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrBigEndian { + _params: D9BigEndianParams { + create_accounts_proof: proof_result.create_accounts_proof, + value, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrBigEndian should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrMultiU64 - seeds = [b"multi_u64", params.id.to_le_bytes().as_ref(), params.counter.to_le_bytes().as_ref()] +#[tokio::test] +async fn test_d9_instr_multi_u64() { + use csdk_anchor_full_derived_test::D9MultiU64Params; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let id = 100u64; + let counter = 200u64; + let (record_pda, _) = Pubkey::find_program_address( + &[ + b"multi_u64", + id.to_le_bytes().as_ref(), + counter.to_le_bytes().as_ref(), + ], + &program_id, + ); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrMultiU64 { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrMultiU64 { + _params: D9MultiU64Params { + create_accounts_proof: proof_result.create_accounts_proof, + id, + counter, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrMultiU64 should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrChainedAsRef - seeds = [b"instr_chain", params.key.as_ref()] +#[tokio::test] +async fn test_d9_instr_chained_as_ref() { + use csdk_anchor_full_derived_test::D9ChainedAsRefParams; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let key = Keypair::new().pubkey(); + let (record_pda, _) = + Pubkey::find_program_address(&[b"instr_chain", key.as_ref()], &program_id); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrChainedAsRef { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrChainedAsRef { + params: D9ChainedAsRefParams { + create_accounts_proof: proof_result.create_accounts_proof, + key, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrChainedAsRef should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrConstMixed - seeds = [D9_INSTR_SEED, params.owner.as_ref()] +#[tokio::test] +async fn test_d9_instr_const_mixed() { + use csdk_anchor_full_derived_test::{ + instructions::d9_seeds::instruction_data::D9_INSTR_SEED, D9ConstMixedParams, + }; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + + let owner = Keypair::new().pubkey(); + let (record_pda, _) = + Pubkey::find_program_address(&[D9_INSTR_SEED, owner.as_ref()], &program_id); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrConstMixed { + fee_payer: payer.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrConstMixed { + params: D9ConstMixedParams { + create_accounts_proof: proof_result.create_accounts_proof, + owner, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("D9InstrConstMixed should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +} + +/// Test D9InstrComplexMixed - seeds = [b"complex", authority.key().as_ref(), params.data_owner.as_ref(), ¶ms.data_amount.to_le_bytes()] +#[tokio::test] +async fn test_d9_instr_complex_mixed() { + use csdk_anchor_full_derived_test::D9ComplexMixedParams; + + let (mut rpc, payer, program_id, config_pda) = setup_d9_test_context().await; + let authority = Keypair::new(); + + let data_owner = Keypair::new().pubkey(); + let data_amount = 55555u64; + let (record_pda, _) = Pubkey::find_program_address( + &[ + b"complex", + authority.pubkey().as_ref(), + data_owner.as_ref(), + &data_amount.to_le_bytes(), + ], + &program_id, + ); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::pda(record_pda)], + ) + .await + .unwrap(); + + let accounts = csdk_anchor_full_derived_test::accounts::D9InstrComplexMixed { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + compression_config: config_pda, + record: record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = csdk_anchor_full_derived_test::instruction::D9InstrComplexMixed { + params: D9ComplexMixedParams { + create_accounts_proof: proof_result.create_accounts_proof, + data_owner, + data_amount, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("D9InstrComplexMixed should succeed"); + + assert!( + rpc.get_account(record_pda).await.unwrap().is_some(), + "Record PDA should exist" + ); +}