From 931a942879ebaa71e5fb803da22ddce8aea910b9 Mon Sep 17 00:00:00 2001 From: ananas Date: Thu, 22 Jan 2026 02:47:15 +0000 Subject: [PATCH 1/3] fix parsing, unify validation, refactor bumps to be similar to anchor --- .../src/light_pdas/account/seed_extraction.rs | 73 ++- .../macros/src/light_pdas/accounts/builder.rs | 39 ++ .../src/light_pdas/accounts/light_account.rs | 549 +++++++++++++++--- .../macros/src/light_pdas/accounts/mint.rs | 79 ++- .../macros/src/light_pdas/accounts/token.rs | 50 +- .../src/light_pdas/light_account_keywords.rs | 142 +++++ sdk-libs/macros/src/light_pdas/mod.rs | 2 + .../src/amm_test/initialize.rs | 6 +- .../src/instruction_accounts.rs | 21 +- .../d10_token_accounts/single_vault.rs | 2 +- sdk-tests/single-mint-test/src/lib.rs | 3 +- sdk-tests/single-token-test/src/lib.rs | 2 +- 12 files changed, 851 insertions(+), 117 deletions(-) create mode 100644 sdk-libs/macros/src/light_pdas/light_account_keywords.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 0d66b06549..0e2c4d992a 100644 --- a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs +++ b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs @@ -8,7 +8,12 @@ use std::collections::HashSet; use syn::{Expr, Ident, ItemStruct, Type}; use crate::{ - light_pdas::shared_utils::{extract_terminal_ident, is_constant_identifier}, + light_pdas::{ + light_account_keywords::{ + is_standalone_keyword, unknown_keyword_error, valid_keywords_for_type, + }, + shared_utils::{extract_terminal_ident, is_constant_identifier}, + }, utils::snake_to_camel_case, }; @@ -305,16 +310,23 @@ fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool) { (false, false) } -/// Parsed #[light_account(token, ...)] attribute +/// Parsed #[light_account(token, ...)] or #[light_account(associated_token, ...)] attribute struct LightTokenAttr { /// Optional variant name - if None, derived from field name variant_name: Option, authority_seeds: Option>, + /// The account type: "token" or "associated_token" + #[allow(dead_code)] + account_type: String, } -/// Extract #[light_account(token, authority = [...])] attribute +/// Extract #[light_account(token, ...)] attribute /// Variant name is derived from field name, not specified in attribute /// Returns Err if the attribute exists but has malformed syntax +/// +/// Note: This function currently only handles `token` accounts, not `associated_token`. +/// Associated token accounts are handled differently (they use `owner` instead of `authority`). +/// The ExtractedTokenSpec struct is designed for token accounts with authority seeds. fn extract_light_token_attr( attrs: &[syn::Attribute], instruction_args: &InstructionArgSet, @@ -327,14 +339,15 @@ fn extract_light_token_attr( }; // Check if "token" keyword is present (without requiring "init") + // Note: associated_token is not handled here as it has different semantics let has_token = tokens .clone() .into_iter() .any(|t| matches!(&t, proc_macro2::TokenTree::Ident(ident) if ident == "token")); if has_token { - // Parse authority = [...] - propagate errors instead of swallowing them - let parsed = parse_light_token_list(&tokens, instruction_args)?; + // Parse attribute content - propagate errors instead of swallowing them + let parsed = parse_light_token_list(&tokens, instruction_args, "token")?; return Ok(Some(parsed)); } } @@ -342,26 +355,33 @@ fn extract_light_token_attr( Ok(None) } -/// Parse light_account(token, authority = [...]) content +/// Parse light_account(token, ...) or light_account(associated_token, ...) content +/// Uses shared keywords from light_account_keywords module for consistent validation. fn parse_light_token_list( tokens: &proc_macro2::TokenStream, instruction_args: &InstructionArgSet, + account_type: &str, ) -> syn::Result { use syn::parse::Parser; - // Capture instruction_args for use in closure + // Capture instruction_args and account_type for use in closure let instruction_args = instruction_args.clone(); + let account_type_owned = account_type.to_string(); + let valid_keys = valid_keywords_for_type(account_type); + let parser = move |input: syn::parse::ParseStream| -> syn::Result { let mut authority_seeds = None; - // Parse comma-separated items looking for "token" and "authority = [...]" + // Parse comma-separated items while !input.is_empty() { if input.peek(Ident) { let ident: Ident = input.parse()?; + let ident_str = ident.to_string(); - if ident == "token" { - // Skip the token keyword, continue parsing - } else if ident == "authority" { + // Check if it's a standalone keyword (init, token, associated_token) + if is_standalone_keyword(&ident_str) { + // Standalone keywords, continue parsing + } else if ident_str == "authority" { // Parse authority = [...] input.parse::()?; let array: syn::ExprArray = input.parse()?; @@ -372,21 +392,44 @@ fn parse_light_token_list( } } authority_seeds = Some(seeds); + } else if valid_keys.contains(&ident_str.as_str()) { + // Valid keyword for this account type (mint, owner, bump) + // Check if it has a value (= expr) or is shorthand + if input.peek(syn::Token![=]) { + input.parse::()?; + let _expr: syn::Expr = input.parse()?; + } + // If no = follows, it's shorthand syntax (e.g., `mint` means `mint = mint`) + // For seed_extraction, we just ignore the value as it's informational + } else { + // Unknown keyword - generate error using shared function + return Err(syn::Error::new_spanned( + &ident, + unknown_keyword_error(&ident_str, &account_type_owned), + )); } + } else { + // Non-identifier token - error + let valid_kw_str = valid_keys.join(", "); + return Err(syn::Error::new( + input.span(), + format!( + "Expected keyword in #[light_account({}, ...)]. Valid keywords: init, {}, {}", + account_type_owned, account_type_owned, valid_kw_str + ), + )); } - // Skip comma if present + // Consume comma if present if input.peek(syn::Token![,]) { input.parse::()?; - } else if !input.is_empty() { - // Skip unexpected tokens - let _: proc_macro2::TokenTree = input.parse()?; } } Ok(LightTokenAttr { variant_name: None, // Variant name is always derived from field name authority_seeds, + account_type: account_type_owned.clone(), }) }; diff --git a/sdk-libs/macros/src/light_pdas/accounts/builder.rs b/sdk-libs/macros/src/light_pdas/accounts/builder.rs index 491c4bdc56..548962587f 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/builder.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/builder.rs @@ -84,6 +84,45 @@ impl LightAccountsBuilder { // Validate infrastructure fields are present self.validate_infra_fields()?; + // Validate CreateAccountsProof is available + self.validate_create_accounts_proof()?; + + Ok(()) + } + + /// Validate that CreateAccountsProof is available when needed. + /// + /// CreateAccountsProof is required when there are any init fields (PDAs, mints). + /// It can be provided either: + /// - As a direct argument: `proof: CreateAccountsProof` + /// - As a field on the first instruction arg: `params.create_accounts_proof` + fn validate_create_accounts_proof(&self) -> Result<(), syn::Error> { + let needs_proof = self.has_pdas() || self.has_mints(); + + if !needs_proof { + return Ok(()); + } + + // Check if CreateAccountsProof is available + let has_direct_proof = self.parsed.direct_proof_arg.is_some(); + let has_instruction_args = self + .parsed + .instruction_args + .as_ref() + .map(|args| !args.is_empty()) + .unwrap_or(false); + + if !has_direct_proof && !has_instruction_args { + return Err(syn::Error::new_spanned( + &self.parsed.struct_name, + "CreateAccountsProof is required for #[light_account(init)] fields.\n\ + \n\ + Provide it either:\n\ + 1. As a direct argument: #[instruction(proof: CreateAccountsProof)]\n\ + 2. As a field on params: #[instruction(params: MyParams)] where MyParams has a `create_accounts_proof: CreateAccountsProof` field", + )); + } + Ok(()) } 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 022dcf3026..e413258d68 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs @@ -15,6 +15,9 @@ use syn::{ use super::mint::LightMintField; pub(super) use crate::light_pdas::account::seed_extraction::extract_account_inner_type; +use crate::light_pdas::light_account_keywords::{ + is_shorthand_keyword, unknown_keyword_error, valid_keywords_for_type, +}; // ============================================================================ // Account Type Classification @@ -62,11 +65,14 @@ pub struct TokenAccountField { /// True if `init` keyword is present (generate creation code) pub has_init: bool, /// Authority seeds for the PDA owner (from authority = [...] parameter) + /// Note: Seeds should NOT include the bump - it's auto-derived or passed via `bump` 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, + /// Optional bump seed. If None, bump is auto-derived using find_program_address. + pub bump: Option, } /// A field marked with #[light_account([init,] ata, ...)] (Associated Token Account). @@ -201,12 +207,8 @@ fn parse_token_ata_key_values( ) -> 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"][..] - } else { - // associated_token - &["owner", "mint", "bump"][..] - }; + let account_type_str = account_type_name.to_string(); + let valid_keys = valid_keywords_for_type(&account_type_str); while !input.is_empty() { input.parse::()?; @@ -233,13 +235,7 @@ fn parse_token_ata_key_values( 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(", ") - ), + unknown_keyword_error(&key_str, &account_type_str), )); } @@ -266,7 +262,7 @@ fn parse_token_ata_key_values( } } else { // Shorthand: key alone means key = key (for mint, owner, bump) - if key_str == "mint" || key_str == "owner" || key_str == "bump" { + if is_shorthand_keyword(&key_str) { syn::parse_quote!(#key) } else { return Err(Error::new_spanned( @@ -306,7 +302,32 @@ pub(super) fn parse_light_account_attr( // Mark-only mode (token/ata without init) - handled by light_program macro // Return None so LightAccounts derive skips them + // But still validate that required parameters are present if args.is_token && !args.has_init { + // For mark-only token, authority is required but mint/owner are NOT allowed + if args.account_type == LightAccountType::Token { + let has_authority = args.key_values.iter().any(|kv| kv.key == "authority"); + if !has_authority { + return Err(Error::new_spanned( + attr, + "#[light_account(token, ...)] requires `authority = [...]` parameter", + )); + } + // mint and owner are only for init mode + for kv in &args.key_values { + let key = kv.key.to_string(); + if key == "mint" || key == "owner" { + return Err(Error::new_spanned( + &kv.key, + format!( + "`{}` is only allowed with `init`. \ + For mark-only token, use: #[light_account(token, authority = [...])]", + key + ), + )); + } + } + } return Ok(None); } @@ -352,39 +373,33 @@ fn build_pda_field( key_values: &[KeyValue], direct_proof_arg: &Option, ) -> Result { - let mut address_tree_info: Option = None; - let mut output_tree: Option = None; - - for kv in key_values { - match kv.key.to_string().as_str() { - "address_tree_info" => address_tree_info = Some(kv.value.clone()), - "output_tree" => output_tree = Some(kv.value.clone()), - other => { - return Err(Error::new_spanned( - &kv.key, - format!( - "Unknown argument `{other}` for PDA. Expected: address_tree_info, output_tree" - ), - )); - } - } + // Reject any key-value pairs - PDA only needs `init` + // Tree info is always auto-fetched from CreateAccountsProof + if !key_values.is_empty() { + let keys: Vec<_> = key_values.iter().map(|kv| kv.key.to_string()).collect(); + return Err(Error::new_spanned( + &key_values[0].key, + format!( + "Unexpected arguments for PDA: {}. \ + #[light_account(init)] takes no additional arguments. \ + address_tree_info and output_tree are automatically sourced from CreateAccountsProof.", + keys.join(", ") + ), + )); } - // Use defaults if not specified - depends on whether CreateAccountsProof is direct arg or nested - let address_tree_info = address_tree_info.unwrap_or_else(|| { - if let Some(proof_ident) = direct_proof_arg { - syn::parse_quote!(#proof_ident.address_tree_info) - } else { - syn::parse_quote!(params.create_accounts_proof.address_tree_info) - } - }); - let output_tree = output_tree.unwrap_or_else(|| { - if let Some(proof_ident) = direct_proof_arg { - syn::parse_quote!(#proof_ident.output_state_tree_index) - } else { - syn::parse_quote!(params.create_accounts_proof.output_state_tree_index) - } - }); + // Always fetch from CreateAccountsProof + let (address_tree_info, output_tree) = if let Some(proof_ident) = direct_proof_arg { + ( + syn::parse_quote!(#proof_ident.address_tree_info), + syn::parse_quote!(#proof_ident.output_state_tree_index), + ) + } else { + ( + syn::parse_quote!(params.create_accounts_proof.address_tree_info), + syn::parse_quote!(params.create_accounts_proof.output_state_tree_index), + ) + }; // Validate this is an Account type (or Box) let (is_boxed, inner_type) = extract_account_inner_type(&field.ty).ok_or_else(|| { @@ -421,9 +436,10 @@ fn build_mint_field( let mut mint_seeds: Option = None; // Optional fields - let mut address_tree_info: Option = None; let mut freeze_authority: Option = None; let mut authority_seeds: Option = None; + let mut mint_bump: Option = None; + let mut authority_bump: Option = None; let mut rent_payment: Option = None; let mut write_top_up: Option = None; @@ -440,11 +456,18 @@ fn build_mint_field( "authority" => authority = Some(kv.value.clone()), "decimals" => decimals = Some(kv.value.clone()), "mint_seeds" => mint_seeds = Some(kv.value.clone()), - "address_tree_info" => address_tree_info = Some(kv.value.clone()), + "address_tree_info" | "output_tree" => { + return Err(Error::new_spanned( + &kv.key, + "address_tree_info and output_tree are automatically sourced from CreateAccountsProof", + )); + } "freeze_authority" => { freeze_authority = Some(expr_to_ident(&kv.value, "freeze_authority")?); } "authority_seeds" => authority_seeds = Some(kv.value.clone()), + "mint_bump" => mint_bump = Some(kv.value.clone()), + "authority_bump" => authority_bump = Some(kv.value.clone()), "rent_payment" => rent_payment = Some(kv.value.clone()), "write_top_up" => write_top_up = Some(kv.value.clone()), "name" => name = Some(kv.value.clone()), @@ -499,14 +522,18 @@ fn build_mint_field( attr, )?; - // address_tree_info defaults - depends on whether CreateAccountsProof is direct arg or nested - let address_tree_info = address_tree_info.unwrap_or_else(|| { - if let Some(proof_ident) = direct_proof_arg { - syn::parse_quote!(#proof_ident.address_tree_info) - } else { - syn::parse_quote!(params.create_accounts_proof.address_tree_info) - } - }); + // Always fetch from CreateAccountsProof - depends on whether proof is direct arg or nested + let (address_tree_info, output_tree) = if let Some(proof_ident) = direct_proof_arg { + ( + syn::parse_quote!(#proof_ident.address_tree_info), + syn::parse_quote!(#proof_ident.output_state_tree_index), + ) + } else { + ( + syn::parse_quote!(params.create_accounts_proof.address_tree_info), + syn::parse_quote!(params.create_accounts_proof.output_state_tree_index), + ) + }; Ok(LightMintField { field_ident: field_ident.clone(), @@ -514,9 +541,12 @@ fn build_mint_field( authority, decimals, address_tree_info, + output_tree, freeze_authority, mint_seeds, + mint_bump, authority_seeds, + authority_bump, rent_payment, write_top_up, name, @@ -537,32 +567,50 @@ fn build_token_account_field( let mut authority: Option = None; let mut mint: Option = None; let mut owner: Option = None; + let mut bump: 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()), + "bump" => bump = Some(kv.value.clone()), other => { return Err(Error::new_spanned( &kv.key, format!( "Unknown argument `{other}` for token. \ - Expected: authority, mint, owner" + Expected: authority, mint, owner, bump" ), )); } } } - // Validate required fields for init mode - if has_init && authority.is_none() { + // authority is ALWAYS required (mark-only and init modes) + if authority.is_none() { return Err(Error::new_spanned( attr, - "#[light_account(init, token, ...)] requires `authority = [...]` parameter", + "#[light_account(token, ...)] requires `authority = [...]` parameter", )); } + // mint and owner are required for init mode + if has_init { + if mint.is_none() { + return Err(Error::new_spanned( + attr, + "#[light_account(init, token, ...)] requires `mint` parameter", + )); + } + if owner.is_none() { + return Err(Error::new_spanned( + attr, + "#[light_account(init, token, ...)] requires `owner` parameter", + )); + } + } + // Extract authority seeds from the array expression let authority_seeds = if let Some(ref auth_expr) = authority { let seeds = extract_array_elements(auth_expr)?; @@ -584,6 +632,7 @@ fn build_token_account_field( authority_seeds, mint, owner, + bump, }) } @@ -752,7 +801,8 @@ mod tests { } #[test] - fn test_parse_light_account_pda_with_options() { + fn test_parse_pda_tree_keywords_rejected() { + // Tree keywords are no longer allowed - they're auto-fetched from CreateAccountsProof let field: syn::Field = parse_quote! { #[light_account(init, address_tree_info = custom_tree, output_tree = custom_output)] pub record: Account<'info, MyRecord> @@ -760,14 +810,13 @@ mod tests { let ident = field.ident.clone().unwrap(); let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::Pda(_) => {} - _ => panic!("Expected PDA field"), - } + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("Unexpected arguments") || err.contains("automatically sourced"), + "Expected error about rejected tree keywords, got: {}", + err + ); } #[test] @@ -902,7 +951,7 @@ mod tests { #[test] fn test_parse_token_init_creates_field() { let field: syn::Field = parse_quote! { - #[light_account(init, token, authority = [b"authority"])] + #[light_account(init, token, authority = [b"authority"], mint = token_mint, owner = vault_authority)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -917,6 +966,8 @@ mod tests { assert_eq!(token.field_ident.to_string(), "vault"); assert!(token.has_init); assert!(!token.authority_seeds.is_empty()); + assert!(token.mint.is_some()); + assert!(token.owner.is_some()); } _ => panic!("Expected TokenAccount field"), } @@ -936,6 +987,126 @@ mod tests { assert!(err.contains("authority")); } + #[test] + fn test_parse_token_mark_only_missing_authority_fails() { + // Mark-only token now requires authority + let field: syn::Field = parse_quote! { + #[light_account(token)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("authority"), + "Expected error about missing authority, got: {}", + err + ); + } + + #[test] + fn test_parse_token_mark_only_rejects_mint() { + // Mark-only token should not allow mint parameter + let field: syn::Field = parse_quote! { + #[light_account(token, authority = [b"auth"], mint = token_mint)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("mint") && err.contains("only allowed with `init`"), + "Expected error about mint only for init, got: {}", + err + ); + } + + #[test] + fn test_parse_token_mark_only_rejects_owner() { + // Mark-only token should not allow owner parameter + let field: syn::Field = parse_quote! { + #[light_account(token, authority = [b"auth"], owner = vault_authority)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("owner") && err.contains("only allowed with `init`"), + "Expected error about owner only for init, got: {}", + err + ); + } + + #[test] + fn test_parse_token_init_missing_mint_fails() { + // Token init requires mint parameter + let field: syn::Field = parse_quote! { + #[light_account(init, token, authority = [b"authority"], owner = vault_authority)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("mint"), + "Expected error about missing mint, got: {}", + err + ); + } + + #[test] + fn test_parse_token_init_missing_owner_fails() { + // Token init requires owner parameter + let field: syn::Field = parse_quote! { + #[light_account(init, token, authority = [b"authority"], mint = token_mint)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("owner"), + "Expected error about missing owner, got: {}", + err + ); + } + + #[test] + fn test_parse_mint_tree_keywords_rejected() { + // Tree keywords are no longer allowed for mint - they're auto-fetched from CreateAccountsProof + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"test"], + address_tree_info = custom_tree + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("automatically sourced"), + "Expected error about auto-sourced tree info, got: {}", + err + ); + } + // ======================================================================== // Associated Token Tests // ======================================================================== @@ -1098,7 +1269,7 @@ mod tests { 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(init, token, authority = [])] + #[light_account(init, token, authority = [], mint = token_mint, owner = vault_authority)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1232,8 +1403,246 @@ mod tests { "address_tree_info should access .address_tree_info field, got: {}", addr_tree_str ); + + // Verify default output_tree uses the direct proof identifier + // Should be: create_proof.output_state_tree_index + let output_tree = &mint.output_tree; + let output_tree_str = quote::quote!(#output_tree).to_string(); + assert!( + output_tree_str.contains("create_proof"), + "output_tree should reference 'create_proof', got: {}", + output_tree_str + ); + assert!( + output_tree_str.contains("output_state_tree_index"), + "output_tree should access .output_state_tree_index field, got: {}", + output_tree_str + ); + } + _ => panic!("Expected Mint field"), + } + } + + // ======================================================================== + // Bump Parameter Tests + // ======================================================================== + + #[test] + fn test_parse_token_with_bump_parameter() { + // Test token with explicit bump parameter + let field: syn::Field = parse_quote! { + #[light_account(init, token, + authority = [b"vault", self.offer.key()], + mint = token_mint, + owner = vault_authority, + bump = params.vault_bump + )] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with bump parameter" + ); + 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.authority_seeds.is_empty()); + assert!(token.bump.is_some(), "bump should be Some when provided"); + } + _ => panic!("Expected TokenAccount field"), + } + } + + #[test] + fn test_parse_token_without_bump_backwards_compatible() { + // Test token without bump (backwards compatible - bump will be auto-derived) + let field: syn::Field = parse_quote! { + #[light_account(init, token, + authority = [b"vault", self.offer.key()], + mint = token_mint, + owner = vault_authority + )] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully without bump parameter" + ); + 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.authority_seeds.is_empty()); + assert!( + token.bump.is_none(), + "bump should be None when not provided" + ); + } + _ => panic!("Expected TokenAccount field"), + } + } + + #[test] + fn test_parse_mint_with_mint_bump() { + // Test mint with explicit mint_bump parameter + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint"], + mint_bump = params.mint_bump + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with mint_bump parameter" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + assert!( + mint.mint_bump.is_some(), + "mint_bump should be Some when provided" + ); } _ => panic!("Expected Mint field"), } } + + #[test] + fn test_parse_mint_with_authority_bump() { + // Test mint with authority_seeds and authority_bump + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint"], + authority_seeds = &[b"auth"], + authority_bump = params.auth_bump + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with authority_bump parameter" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + assert!( + mint.authority_seeds.is_some(), + "authority_seeds should be Some" + ); + assert!( + mint.authority_bump.is_some(), + "authority_bump should be Some when provided" + ); + } + _ => panic!("Expected Mint field"), + } + } + + #[test] + fn test_parse_mint_without_bumps_backwards_compatible() { + // Test mint without bump parameters (backwards compatible - bumps will be auto-derived) + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint_signer = mint_signer, + authority = authority, + decimals = 9, + mint_seeds = &[b"mint"], + authority_seeds = &[b"auth"] + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully without bump parameters" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + assert!( + mint.mint_bump.is_none(), + "mint_bump should be None when not provided" + ); + assert!( + mint.authority_seeds.is_some(), + "authority_seeds should be Some" + ); + assert!( + mint.authority_bump.is_none(), + "authority_bump should be None when not provided" + ); + } + _ => panic!("Expected Mint field"), + } + } + + #[test] + fn test_parse_token_bump_shorthand_syntax() { + // Test token with bump shorthand syntax (bump = bump) + let field: syn::Field = parse_quote! { + #[light_account(init, token, + authority = [b"vault"], + mint = token_mint, + owner = vault_authority, + bump + )] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with bump shorthand" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::TokenAccount(token) => { + assert!( + token.bump.is_some(), + "bump should be Some with shorthand syntax" + ); + } + _ => panic!("Expected TokenAccount field"), + } + } } diff --git a/sdk-libs/macros/src/light_pdas/accounts/mint.rs b/sdk-libs/macros/src/light_pdas/accounts/mint.rs index f15194b2cf..1e75421e44 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/mint.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/mint.rs @@ -32,14 +32,20 @@ pub(super) struct LightMintField { pub authority: Expr, /// Decimals for the mint pub decimals: Expr, - /// Address tree info expression + /// Address tree info expression (auto-fetched from CreateAccountsProof) pub address_tree_info: Expr, + /// Output state tree index expression (auto-fetched from CreateAccountsProof) + pub output_tree: Expr, /// Optional freeze authority pub freeze_authority: Option, - /// Signer seeds for the mint_signer PDA (required) + /// Signer seeds for the mint_signer PDA (required, WITHOUT bump - bump is auto-derived or provided via mint_bump) pub mint_seeds: Expr, - /// Signer seeds for the authority PDA (optional - if not provided, authority must be a tx signer) + /// Optional bump for mint_seeds. If None, auto-derived using find_program_address. + pub mint_bump: Option, + /// Signer seeds for the authority PDA (optional - if not provided, authority must be a tx signer, WITHOUT bump) pub authority_seeds: Option, + /// Optional bump for authority_seeds. If None, auto-derived using find_program_address. + pub authority_bump: Option, /// Rent payment epochs for decompression (default: 2) pub rent_payment: Option, /// Write top-up lamports for decompression (default: 0) @@ -210,17 +216,56 @@ fn generate_mints_invocation(builder: &LightMintsBuilder) -> TokenStream { let bump_ident = format_ident!("__mint_bump_{}", idx); let signer_key_ident = format_ident!("__mint_signer_key_{}", idx); let mint_seeds_ident = format_ident!("__mint_seeds_{}", idx); + let mint_seeds_with_bump_ident = format_ident!("__mint_seeds_with_bump_{}", idx); + let mint_signer_bump_ident = format_ident!("__mint_signer_bump_{}", idx); let authority_seeds_ident = format_ident!("__authority_seeds_{}", idx); + let authority_seeds_with_bump_ident = format_ident!("__authority_seeds_with_bump_{}", idx); + let authority_bump_ident = format_ident!("__authority_bump_{}", idx); let token_metadata_ident = format_ident!("__mint_token_metadata_{}", idx); - // Generate optional authority seeds binding + // Generate mint_seeds binding with bump derivation/appending + // User provides base seeds WITHOUT bump, we auto-derive or use provided bump + let mint_bump_derivation = mint.mint_bump + .as_ref() + .map(|b| quote! { let #mint_signer_bump_ident: u8 = #b; }) + .unwrap_or_else(|| { + // Auto-derive bump from mint_seeds + quote! { + let #mint_signer_bump_ident: u8 = { + let (_, bump) = solana_pubkey::Pubkey::find_program_address(#mint_seeds_ident, &crate::ID); + bump + }; + } + }); + + // Generate optional authority seeds binding with bump derivation/appending let authority_seeds_binding = match authority_seeds { - Some(seeds) => quote! { - let #authority_seeds_ident: &[&[u8]] = #seeds; - let #authority_seeds_ident = Some(#authority_seeds_ident); + Some(seeds) => { + let authority_bump_derivation = mint.authority_bump + .as_ref() + .map(|b| quote! { let #authority_bump_ident: u8 = #b; }) + .unwrap_or_else(|| { + // Auto-derive bump from authority_seeds + quote! { + let #authority_bump_ident: u8 = { + let base_seeds: &[&[u8]] = #seeds; + let (_, bump) = solana_pubkey::Pubkey::find_program_address(base_seeds, &crate::ID); + bump + }; + } + }); + quote! { + let #authority_seeds_ident: &[&[u8]] = #seeds; + #authority_bump_derivation + // Build Vec with bump appended (using Vec since we can't create fixed-size array at compile time) + let mut #authority_seeds_with_bump_ident: Vec<&[u8]> = #authority_seeds_ident.to_vec(); + let __auth_bump_slice: &[u8] = &[#authority_bump_ident]; + #authority_seeds_with_bump_ident.push(__auth_bump_slice); + let #authority_seeds_with_bump_ident: Option> = Some(#authority_seeds_with_bump_ident); + } }, None => quote! { - let #authority_seeds_ident: Option<&[&[u8]]> = None; + let #authority_seeds_with_bump_ident: Option> = None; }, }; @@ -262,7 +307,14 @@ fn generate_mints_invocation(builder: &LightMintsBuilder) -> TokenStream { let #signer_key_ident = *self.#mint_signer.to_account_info().key; let (#pda_ident, #bump_ident) = light_token::instruction::find_mint_address(&#signer_key_ident); + // Bind base mint_seeds (WITHOUT bump) and derive/get bump let #mint_seeds_ident: &[&[u8]] = #mint_seeds; + #mint_bump_derivation + // Build Vec with bump appended + let mut #mint_seeds_with_bump_ident: Vec<&[u8]> = #mint_seeds_ident.to_vec(); + let __mint_bump_slice: &[u8] = &[#mint_signer_bump_ident]; + #mint_seeds_with_bump_ident.push(__mint_bump_slice); + #authority_seeds_binding #token_metadata_binding @@ -277,8 +329,8 @@ fn generate_mints_invocation(builder: &LightMintsBuilder) -> TokenStream { bump: #bump_ident, freeze_authority: #freeze_authority, mint_seed_pubkey: #signer_key_ident, - authority_seeds: #authority_seeds_ident, - mint_signer_seeds: Some(#mint_seeds_ident), + authority_seeds: #authority_seeds_with_bump_ident.as_deref(), + mint_signer_seeds: Some(&#mint_seeds_with_bump_ident[..]), token_metadata: #token_metadata_ident.as_ref(), }; } @@ -311,9 +363,10 @@ fn generate_mints_invocation(builder: &LightMintsBuilder) -> TokenStream { }) .collect(); - // Get rent_payment and write_top_up from first mint (all mints share same params for now) + // Get shared params from first mint (all mints share same params for now) let rent_payment = quote_option_or(&mints[0].rent_payment, quote! { 16u8 }); let write_top_up = quote_option_or(&mints[0].write_top_up, quote! { 766u32 }); + let output_tree = &mints[0].output_tree; // Authority signer check for mints without authority_seeds let authority_signer_checks: Vec = mints @@ -356,11 +409,11 @@ fn generate_mints_invocation(builder: &LightMintsBuilder) -> TokenStream { ]; // Get tree accounts and indices - // Output queue for state (compressed accounts) is at tree index 0 + // Output queue for state (compressed accounts) uses output_state_tree_index from proof // State merkle tree index comes from the proof (set by pack_proof_for_mints) // Address merkle tree index comes from the proof's address_tree_info let __tree_info = &#proof_access.address_tree_info; - let __output_queue_index: u8 = 0; + let __output_queue_index: u8 = #output_tree; let __state_tree_index: u8 = #proof_access.state_tree_index .ok_or(anchor_lang::prelude::ProgramError::InvalidArgument)?; let __address_tree_index: u8 = __tree_info.address_merkle_tree_pubkey_index; diff --git a/sdk-libs/macros/src/light_pdas/accounts/token.rs b/sdk-libs/macros/src/light_pdas/accounts/token.rs index 64fac5e472..fa1985e16c 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/token.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/token.rs @@ -29,6 +29,11 @@ use super::{ /// Generate token account creation CPI code for a single token account field. /// /// Generated code uses `CreateTokenAccountCpi` with rent-free mode and PDA signing. +/// +/// Bump handling: +/// - If `bump` parameter is provided, uses that value +/// - If `bump` is not provided, auto-derives using `Pubkey::find_program_address()` +/// - Bump is always appended as the final seed in the signer seeds #[allow(dead_code)] pub(super) fn generate_token_account_cpi( field: &TokenAccountField, @@ -44,18 +49,18 @@ pub(super) fn generate_token_account_cpi( let light_token_rent_sponsor = &infra.light_token_rent_sponsor; let fee_payer = &infra.fee_payer; - // Generate authority seeds array from parsed seeds + // Generate authority seeds array from parsed seeds (WITHOUT bump - bump is added separately) // 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]] + // User provides expressions WITHOUT bump in the array: + // authority = [SEED, self.mint.key()] // 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] + // // bump is auto-derived or provided via bump parameter + // &[__seed_0_ref, __seed_1_ref, &[bump]] let authority_seeds = &field.authority_seeds; let seed_bindings: Vec = authority_seeds .iter() @@ -78,10 +83,37 @@ pub(super) fn generate_token_account_cpi( quote! { #ref_name } }) .collect(); + + // Get bump - either from parameter or auto-derive using find_program_address + let bump_derivation = field + .bump + .as_ref() + .map(|b| quote! { let __bump: u8 = #b; }) + .unwrap_or_else(|| { + // Auto-derive bump from seeds + if authority_seeds.is_empty() { + quote! { + let __bump: u8 = { + let (_, bump) = solana_pubkey::Pubkey::find_program_address(&[], &crate::ID); + bump + }; + } + } else { + quote! { + let __bump: u8 = { + let seeds: &[&[u8]] = &[#(#seed_refs),*]; + let (_, bump) = solana_pubkey::Pubkey::find_program_address(seeds, &crate::ID); + bump + }; + } + } + }); + + // Build seeds array with bump appended as final seed let seeds_array_expr = if authority_seeds.is_empty() { - quote! { &[] } + quote! { &[&__bump_slice[..]] } } else { - quote! { &[#(#seed_refs),*] } + quote! { &[#(#seed_refs,)* &__bump_slice[..]] } }; // Get mint and owner from field or derive from context @@ -106,6 +138,10 @@ pub(super) fn generate_token_account_cpi( // Bind seeds to local variables to extend temporary lifetimes #(#seed_bindings)* + + // Get bump - either provided or auto-derived + #bump_derivation + let __bump_slice: [u8; 1] = [__bump]; let __token_account_seeds: &[&[u8]] = #seeds_array_expr; CreateTokenAccountCpi { diff --git a/sdk-libs/macros/src/light_pdas/light_account_keywords.rs b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs new file mode 100644 index 0000000000..712f0f08c2 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs @@ -0,0 +1,142 @@ +//! Shared keyword definitions for `#[light_account(...)]` attribute parsing. +//! +//! This module provides a single source of truth for valid keywords used in +//! `#[light_account(...)]` attributes across both: +//! - `accounts/light_account.rs` - Used by `#[derive(LightAccounts)]` +//! - `account/seed_extraction.rs` - Used by `#[light_program]` + +/// Valid keywords for `#[light_account(token, ...)]` attributes. +pub const TOKEN_KEYWORDS: &[&str] = &["authority", "mint", "owner", "bump"]; + +/// Valid keywords for `#[light_account(associated_token, ...)]` attributes. +pub const ASSOCIATED_TOKEN_KEYWORDS: &[&str] = &["owner", "mint", "bump"]; + +/// Standalone keywords that don't require a value (flags). +/// These can appear as bare identifiers without `= value`. +pub const STANDALONE_KEYWORDS: &[&str] = &["init", "token", "associated_token"]; + +/// Keywords that support shorthand syntax (key alone means key = key). +/// For example, `mint` alone is equivalent to `mint = mint`. +pub const SHORTHAND_KEYWORDS: &[&str] = &["mint", "owner", "bump"]; + +/// Check if a keyword is a standalone flag (doesn't require a value). +#[inline] +pub fn is_standalone_keyword(keyword: &str) -> bool { + STANDALONE_KEYWORDS.contains(&keyword) +} + +/// Check if a keyword supports shorthand syntax. +#[inline] +pub fn is_shorthand_keyword(keyword: &str) -> bool { + SHORTHAND_KEYWORDS.contains(&keyword) +} + +/// Get the valid keywords for a given account type. +/// +/// # Arguments +/// * `account_type` - Either "token" or "associated_token" +/// +/// # Returns +/// A slice of valid keyword strings for the account type. +pub fn valid_keywords_for_type(account_type: &str) -> &'static [&'static str] { + match account_type { + "token" => TOKEN_KEYWORDS, + "associated_token" => ASSOCIATED_TOKEN_KEYWORDS, + _ => &[], + } +} + +/// Generate an error message for an unknown keyword. +/// +/// # Arguments +/// * `keyword` - The unknown keyword that was encountered +/// * `account_type` - The account type being parsed ("token" or "associated_token") +/// +/// # Returns +/// A formatted error message string. +pub fn unknown_keyword_error(keyword: &str, account_type: &str) -> String { + let valid = valid_keywords_for_type(account_type); + format!( + "Unknown argument `{}` in #[light_account({}, ...)]. Allowed: {}", + keyword, + account_type, + valid.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_token_keywords() { + assert!(TOKEN_KEYWORDS.contains(&"authority")); + assert!(TOKEN_KEYWORDS.contains(&"mint")); + assert!(TOKEN_KEYWORDS.contains(&"owner")); + assert!(TOKEN_KEYWORDS.contains(&"bump")); // bump is now a valid token keyword + assert!(!TOKEN_KEYWORDS.contains(&"unknown")); + } + + #[test] + fn test_ata_keywords() { + assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"owner")); + assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"mint")); + assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"bump")); + assert!(!ASSOCIATED_TOKEN_KEYWORDS.contains(&"authority")); + assert!(!ASSOCIATED_TOKEN_KEYWORDS.contains(&"unknown")); + } + + #[test] + fn test_standalone_keywords() { + assert!(is_standalone_keyword("init")); + assert!(is_standalone_keyword("token")); + assert!(is_standalone_keyword("associated_token")); + assert!(!is_standalone_keyword("authority")); + assert!(!is_standalone_keyword("mint")); + } + + #[test] + fn test_shorthand_keywords() { + assert!(is_shorthand_keyword("mint")); + assert!(is_shorthand_keyword("owner")); + assert!(is_shorthand_keyword("bump")); + assert!(!is_shorthand_keyword("authority")); + assert!(!is_shorthand_keyword("init")); + } + + #[test] + fn test_valid_keywords_for_type() { + let token_kw = valid_keywords_for_type("token"); + assert_eq!(token_kw, TOKEN_KEYWORDS); + + let ata_kw = valid_keywords_for_type("associated_token"); + assert_eq!(ata_kw, ASSOCIATED_TOKEN_KEYWORDS); + + let unknown_kw = valid_keywords_for_type("unknown"); + assert!(unknown_kw.is_empty()); + } + + #[test] + fn test_cross_validation() { + // mint and owner are valid for both token and ATA + assert!(TOKEN_KEYWORDS.contains(&"mint")); + assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"mint")); + assert!(TOKEN_KEYWORDS.contains(&"owner")); + assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"owner")); + } + + #[test] + fn test_unknown_keyword_error() { + let error = unknown_keyword_error("unknown_key", "token"); + assert!(error.contains("unknown_key")); + assert!(error.contains("token")); + assert!(error.contains("authority")); + assert!(error.contains("mint")); + assert!(error.contains("owner")); + + let error = unknown_keyword_error("bad", "associated_token"); + assert!(error.contains("bad")); + assert!(error.contains("associated_token")); + assert!(error.contains("bump")); + } +} diff --git a/sdk-libs/macros/src/light_pdas/mod.rs b/sdk-libs/macros/src/light_pdas/mod.rs index c9be7db2fb..907660739a 100644 --- a/sdk-libs/macros/src/light_pdas/mod.rs +++ b/sdk-libs/macros/src/light_pdas/mod.rs @@ -4,9 +4,11 @@ //! - `program/` - `#[rentfree_program]` attribute macro for program-level auto-discovery //! - `accounts/` - `#[derive(LightAccounts)]` derive macro for Accounts structs //! - `account/` - Trait derive macros for account data structs (Compressible, Pack, HasCompressionInfo, etc.) +//! - `light_account_keywords` - Shared keyword definitions for `#[light_account(...)]` parsing //! - `shared_utils` - Common utilities (constant detection, identifier extraction) pub mod account; pub mod accounts; +pub mod light_account_keywords; pub mod program; pub mod shared_utils; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs index 8aaa7fe1fa..5a4655f479 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/amm_test/initialize.rs @@ -81,8 +81,10 @@ pub struct InitializePool<'info> { mint_signer = lp_mint_signer, authority = authority, decimals = 9, - mint_seeds = &[POOL_LP_MINT_SIGNER_SEED, self.pool_state.to_account_info().key.as_ref(), &[params.lp_mint_signer_bump]], - authority_seeds = &[AUTH_SEED.as_bytes(), &[params.authority_bump]] // TODO: get the authority seeds from authority if defined + mint_seeds = &[POOL_LP_MINT_SIGNER_SEED, self.pool_state.to_account_info().key.as_ref()], + mint_bump = params.lp_mint_signer_bump, + authority_seeds = &[AUTH_SEED.as_bytes()], + authority_bump = params.authority_bump )] pub lp_mint: UncheckedAccount<'info>, diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs index 87d73b9290..a088c5e591 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs @@ -75,7 +75,8 @@ pub struct CreatePdasAndMintAuto<'info> { mint_signer = mint_signer, authority = mint_authority, decimals = 9, - mint_seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]] + mint_seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_bump )] pub mint: UncheckedAccount<'info>, @@ -160,7 +161,8 @@ pub struct CreateTwoMints<'info> { mint_signer = mint_signer_a, authority = fee_payer, decimals = 6, - mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_a_bump]] + mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_a_bump )] pub cmint_a: UncheckedAccount<'info>, @@ -170,7 +172,8 @@ pub struct CreateTwoMints<'info> { mint_signer = mint_signer_b, authority = fee_payer, decimals = 9, - mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_b_bump]] + mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_b_bump )] pub cmint_b: UncheckedAccount<'info>, @@ -243,7 +246,8 @@ pub struct CreateThreeMints<'info> { mint_signer = mint_signer_a, authority = fee_payer, decimals = 6, - mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_a_bump]] + mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_a_bump )] pub cmint_a: UncheckedAccount<'info>, @@ -253,7 +257,8 @@ pub struct CreateThreeMints<'info> { mint_signer = mint_signer_b, authority = fee_payer, decimals = 8, - mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_b_bump]] + mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_b_bump )] pub cmint_b: UncheckedAccount<'info>, @@ -263,7 +268,8 @@ pub struct CreateThreeMints<'info> { mint_signer = mint_signer_c, authority = fee_payer, decimals = 9, - mint_seeds = &[MINT_SIGNER_C_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_c_bump]] + mint_seeds = &[MINT_SIGNER_C_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_c_bump )] pub cmint_c: UncheckedAccount<'info>, @@ -325,7 +331,8 @@ pub struct CreateMintWithMetadata<'info> { mint_signer = mint_signer, authority = fee_payer, decimals = 9, - mint_seeds = &[METADATA_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]], + mint_seeds = &[METADATA_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_bump, name = params.name.clone(), symbol = params.symbol.clone(), uri = params.uri.clone(), 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 index 68a30313f2..f9c834afae 100644 --- 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 @@ -47,7 +47,7 @@ pub struct D10SingleVault<'info> { 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)] + #[light_account(init, token, authority = [D10_SINGLE_VAULT_SEED, self.d10_mint.key()], mint = d10_mint, owner = d10_vault_authority, bump = params.vault_bump)] pub d10_single_vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/single-mint-test/src/lib.rs b/sdk-tests/single-mint-test/src/lib.rs index 27febcaf24..f40581e046 100644 --- a/sdk-tests/single-mint-test/src/lib.rs +++ b/sdk-tests/single-mint-test/src/lib.rs @@ -46,7 +46,8 @@ pub struct CreateMint<'info> { mint_signer = mint_signer, authority = fee_payer, decimals = 9, - mint_seeds = &[MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]] + mint_seeds = &[MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint_bump = params.mint_signer_bump )] pub mint: UncheckedAccount<'info>, diff --git a/sdk-tests/single-token-test/src/lib.rs b/sdk-tests/single-token-test/src/lib.rs index a6cad4093a..c937c22225 100644 --- a/sdk-tests/single-token-test/src/lib.rs +++ b/sdk-tests/single-token-test/src/lib.rs @@ -52,7 +52,7 @@ pub struct CreateTokenVault<'info> { seeds = [VAULT_SEED, mint.key().as_ref()], bump, )] - #[light_account(init, token, authority = [VAULT_SEED, self.mint.key(), &[params.vault_bump]], mint = mint, owner = vault_authority)] + #[light_account(init, token, authority = [VAULT_SEED, self.mint.key()], mint = mint, owner = vault_authority, bump = params.vault_bump)] pub vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] From ea281e8cfe03885c1abddc343371af3c73e615d8 Mon Sep 17 00:00:00 2001 From: ananas Date: Thu, 22 Jan 2026 14:36:52 +0000 Subject: [PATCH 2/3] refactor: macro syntax to match anchor --- sdk-libs/macros/docs/accounts/light_mint.md | 199 ++--- .../src/light_pdas/account/seed_extraction.rs | 289 +++++-- .../macros/src/light_pdas/accounts/derive.rs | 12 +- .../src/light_pdas/accounts/light_account.rs | 724 ++++++++++++------ .../src/light_pdas/light_account_keywords.rs | 280 +++++-- .../src/light_pdas/program/instructions.rs | 30 +- .../macros/src/light_pdas/program/parsing.rs | 2 + .../src/amm_test/initialize.rs | 20 +- .../src/instruction_accounts.rs | 96 +-- .../d10_token_accounts/single_ata.rs | 2 +- .../d10_token_accounts/single_vault.rs | 2 +- .../src/instructions/d5_markers/all.rs | 2 +- .../instructions/d5_markers/light_token.rs | 2 +- .../src/instructions/d7_infra_names/all.rs | 2 +- .../d7_infra_names/light_token_config.rs | 2 +- sdk-tests/single-ata-test/src/lib.rs | 2 +- sdk-tests/single-mint-test/src/lib.rs | 12 +- sdk-tests/single-token-test/src/lib.rs | 2 +- 18 files changed, 1123 insertions(+), 557 deletions(-) diff --git a/sdk-libs/macros/docs/accounts/light_mint.md b/sdk-libs/macros/docs/accounts/light_mint.md index 03557e28b2..851f6a4af6 100644 --- a/sdk-libs/macros/docs/accounts/light_mint.md +++ b/sdk-libs/macros/docs/accounts/light_mint.md @@ -1,15 +1,29 @@ -# `#[light_account(init, mint,...)]` Attribute +# `#[light_account(init, mint::...)]` Attribute ## Overview -The `#[light_account(init, mint,...)]` attribute marks a field in an Anchor Accounts struct for compressed mint creation. When applied to a `Mint` account field, it generates code to create a compressed mint with automatic decompression support. +The `#[light_account(init, mint::...)]` attribute marks a field in an Anchor Accounts struct for compressed mint creation. When applied to a `Mint` account field, it generates code to create a compressed mint with automatic decompression support. -**Source**: `sdk-libs/macros/src/rentfree/accounts/light_mint.rs` +**Source**: `sdk-libs/macros/src/light_pdas/accounts/light_account.rs` + +## Syntax + +All parameters use the Anchor-style `mint::` namespace prefix. The account type is inferred from the namespace: + +```rust +#[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint_signer", &[ctx.bumps.mint_signer]] +)] +pub mint: UncheckedAccount<'info>, +``` ## Usage ```rust -use light_sdk_macros::RentFree; +use light_sdk_macros::LightAccounts; use anchor_lang::prelude::*; #[derive(Accounts, LightAccounts)] @@ -25,13 +39,13 @@ pub struct CreateMint<'info> { pub authority: Signer<'info>, /// The Mint account to create - #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint_signer", &[ctx.bumps.mint_signer]] + #[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint_signer", &[ctx.bumps.mint_signer]] )] - pub mint: Account<'info, Mint>, + pub mint: UncheckedAccount<'info>, // Infrastructure accounts (auto-detected by name) pub light_token_compressible_config: Account<'info, CtokenConfig>, @@ -45,20 +59,21 @@ pub struct CreateMint<'info> { | Attribute | Type | Description | |-----------|------|-------------| -| `mint_signer` | Field reference | The AccountInfo that seeds the mint PDA. The mint address is derived from this signer. | -| `authority` | Field reference | The mint authority. Either a transaction signer or a PDA (if `authority_seeds` is provided). | -| `decimals` | Expression | Token decimals (e.g., `9` for 9 decimal places). | -| `mint_seeds` | Slice expression | PDA signer seeds for `mint_signer`. Must be a `&[&[u8]]` expression that matches the `#[account(seeds = ...)]` on `mint_signer`, **including the bump**. | +| `mint::signer` | Field reference | The AccountInfo that seeds the mint PDA. The mint address is derived from this signer. | +| `mint::authority` | Field reference | The mint authority. Either a transaction signer or a PDA (if `mint::authority_seeds` is provided). | +| `mint::decimals` | Expression | Token decimals (e.g., `9` for 9 decimal places). | +| `mint::seeds` | Slice expression | PDA signer seeds for `mint_signer`. Must be a `&[&[u8]]` expression that matches the `#[account(seeds = ...)]` on `mint_signer`, **including the bump**. | ## Optional Attributes | Attribute | Type | Default | Description | |-----------|------|---------|-------------| -| `address_tree_info` | Expression | `params.create_accounts_proof.address_tree_info` | `PackedAddressTreeInfo` containing tree indices. | -| `freeze_authority` | Field reference | None | Optional freeze authority field. | -| `authority_seeds` | Slice expression | None | PDA signer seeds for `authority`. If not provided, `authority` must be a transaction signer. | -| `rent_payment` | Expression | `2u8` | Rent payment epochs for decompression. | -| `write_top_up` | Expression | `0u32` | Write top-up lamports for decompression. | +| `mint::bump` | Expression | Auto-derived | Explicit bump seed for the mint signer PDA. If not provided, uses `find_program_address`. | +| `mint::freeze_authority` | Field reference | None | Optional freeze authority field. | +| `mint::authority_seeds` | Slice expression | None | PDA signer seeds for `authority`. If not provided, `authority` must be a transaction signer. | +| `mint::authority_bump` | Expression | Auto-derived | Explicit bump seed for authority PDA. | +| `mint::rent_payment` | Expression | `2u8` | Rent payment epochs for decompression. | +| `mint::write_top_up` | Expression | `0u32` | Write top-up lamports for decompression. | ## TokenMetadata Fields @@ -66,31 +81,31 @@ Optional fields for creating a mint with the TokenMetadata extension: | Attribute | Type | Default | Description | |-----------|------|---------|-------------| -| `name` | Expression | - | Token name (expression yielding `Vec`). | -| `symbol` | Expression | - | Token symbol (expression yielding `Vec`). | -| `uri` | Expression | - | Token URI (expression yielding `Vec`). | -| `update_authority` | Field reference | None | Optional update authority for metadata. | -| `additional_metadata` | Expression | None | Additional key-value metadata (expression yielding `Option>`). | +| `mint::name` | Expression | - | Token name (expression yielding `Vec`). | +| `mint::symbol` | Expression | - | Token symbol (expression yielding `Vec`). | +| `mint::uri` | Expression | - | Token URI (expression yielding `Vec`). | +| `mint::update_authority` | Field reference | None | Optional update authority for metadata. | +| `mint::additional_metadata` | Expression | None | Additional key-value metadata (expression yielding `Option>`). | ### Validation Rules -1. **Core fields are all-or-nothing**: `name`, `symbol`, and `uri` must ALL be specified together, or none at all. -2. **Optional fields require core fields**: `update_authority` and `additional_metadata` require `name`, `symbol`, and `uri` to also be specified. +1. **Core fields are all-or-nothing**: `mint::name`, `mint::symbol`, and `mint::uri` must ALL be specified together, or none at all. +2. **Optional fields require core fields**: `mint::update_authority` and `mint::additional_metadata` require `mint::name`, `mint::symbol`, and `mint::uri` to also be specified. ### Metadata Example ```rust -#[light_account(init, mint, - mint_signer = mint_signer, - authority = fee_payer, - decimals = 9, - mint_seeds = &[SEED, self.authority.key().as_ref(), &[params.bump]], +#[light_account(init, + mint::signer = mint_signer, + mint::authority = fee_payer, + mint::decimals = 9, + mint::seeds = &[SEED, self.authority.key().as_ref(), &[params.bump]], // TokenMetadata fields - name = params.name.clone(), - symbol = params.symbol.clone(), - uri = params.uri.clone(), - update_authority = authority, - additional_metadata = params.additional_metadata.clone() + mint::name = params.name.clone(), + mint::symbol = params.symbol.clone(), + mint::uri = params.uri.clone(), + mint::update_authority = authority, + mint::additional_metadata = params.additional_metadata.clone() )] pub mint: UncheckedAccount<'info>, ``` @@ -99,15 +114,15 @@ pub mint: UncheckedAccount<'info>, ```rust // ERROR: name without symbol and uri -#[light_account(init, mint, - ..., - name = params.name.clone() +#[light_account(init, + mint::signer = ..., + mint::name = params.name.clone() )] // ERROR: additional_metadata without name, symbol, uri -#[light_account(init, mint, - ..., - additional_metadata = params.additional_metadata.clone() +#[light_account(init, + mint::signer = ..., + mint::additional_metadata = params.additional_metadata.clone() )] ``` @@ -121,16 +136,17 @@ The mint address is derived from the `mint_signer` field: let (mint_pda, bump) = light_token::instruction::find_mint_address(mint_signer.key); ``` -### Signer Seeds (mint_seeds) +### Signer Seeds (mint::seeds) -The `mint_seeds` attribute provides the PDA signer seeds used for `invoke_signed` when calling the light token program. These seeds must derive to the `mint_signer` pubkey for the CPI to succeed. +The `mint::seeds` attribute provides the PDA signer seeds used for `invoke_signed` when calling the light token program. These seeds must derive to the `mint_signer` pubkey for the CPI to succeed. ```rust -#[light_account(init, mint, - mint_signer = mint_signer, - authority = mint_authority, - decimals = 9, - mint_seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]] +#[light_account(init, + mint::signer = mint_signer, + mint::authority = mint_authority, + mint::decimals = 9, + mint::seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]], + mint::bump = params.mint_signer_bump )] pub mint: UncheckedAccount<'info>, ``` @@ -138,12 +154,12 @@ pub mint: UncheckedAccount<'info>, **Syntax notes:** - Use `self.field` to reference accounts in the struct - Use `.to_account_info().key` to get account pubkeys -- The bump must be passed explicitly (typically via instruction params) +- The bump can be provided explicitly via `mint::bump` or auto-derived The generated code uses these seeds to sign the CPI: ```rust -let mint_seeds: &[&[u8]] = &[...]; // from mint_seeds attribute +let mint_seeds: &[&[u8]] = &[...]; // from mint::seeds attribute invoke_signed(&mint_action_ix, &account_infos, &[mint_seeds])?; ``` @@ -178,13 +194,13 @@ pub struct CreateBasicMint<'info> { pub authority: Signer<'info>, - #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 6, - mint_seeds = &[b"mint", &[ctx.bumps.mint_signer]] + #[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 6, + mint::seeds = &[b"mint", &[ctx.bumps.mint_signer]] )] - pub mint: Account<'info, Mint>, + pub mint: UncheckedAccount<'info>, // ... infrastructure accounts } @@ -192,7 +208,7 @@ pub struct CreateBasicMint<'info> { ### Mint with PDA Authority -When the authority is a PDA, provide `authority_seeds`: +When the authority is a PDA, provide `mint::authority_seeds`: ```rust #[derive(Accounts, LightAccounts)] @@ -209,14 +225,15 @@ pub struct CreateMintWithPdaAuthority<'info> { #[account(seeds = [b"authority"], bump)] pub authority: AccountInfo<'info>, - #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint", &[ctx.bumps.mint_signer]], - authority_seeds = &[b"authority", &[ctx.bumps.authority]] + #[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint", &[ctx.bumps.mint_signer]], + mint::authority_seeds = &[b"authority", &[ctx.bumps.authority]], + mint::authority_bump = params.authority_bump )] - pub mint: Account<'info, Mint>, + pub mint: UncheckedAccount<'info>, // ... infrastructure accounts } @@ -225,14 +242,14 @@ pub struct CreateMintWithPdaAuthority<'info> { ### Mint with Freeze Authority ```rust -#[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint", &[bump]], - freeze_authority = freeze_auth +#[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint", &[bump]], + mint::freeze_authority = freeze_auth )] -pub mint: Account<'info, Mint>, +pub mint: UncheckedAccount<'info>, /// Optional freeze authority pub freeze_auth: Signer<'info>, @@ -241,15 +258,15 @@ pub freeze_auth: Signer<'info>, ### Custom Decompression Settings ```rust -#[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint", &[bump]], - rent_payment = 4, // 4 epochs of rent - write_top_up = 1000 // Extra lamports for writes +#[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint", &[bump]], + mint::rent_payment = 4, // 4 epochs of rent + mint::write_top_up = 1000 // Extra lamports for writes )] -pub mint: Account<'info, Mint>, +pub mint: UncheckedAccount<'info>, ``` ### Combined with #[light_account(init)] PDAs @@ -267,13 +284,13 @@ pub struct CreateMintAndPda<'info> { pub authority: Signer<'info>, - #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint", &[ctx.bumps.mint_signer]] + #[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint", &[ctx.bumps.mint_signer]] )] - pub mint: Account<'info, Mint>, + pub mint: UncheckedAccount<'info>, #[account( init, @@ -289,7 +306,7 @@ pub struct CreateMintAndPda<'info> { } ``` -When both `#[light_account(init)]` and `#[light_account(init)]` are present, the macro: +When both `#[light_account(init)]` and `#[light_account(init, mint::...)]` are present, the macro: 1. Processes PDAs first, writing them to the CPI context 2. Invokes mint_action with CPI context to batch the mint creation 3. Uses `assigned_account_index` to order the mint relative to PDAs @@ -309,12 +326,12 @@ The macro requires certain infrastructure accounts, auto-detected by naming conv ## Validation The macro validates at compile time: -- `mint_signer`, `authority`, `decimals`, and `mint_seeds` are required +- `mint::signer`, `mint::authority`, `mint::decimals`, and `mint::seeds` are required - `#[instruction(...)]` attribute must be present on the struct -- If `authority_seeds` is not provided, the generated code verifies `authority` is a transaction signer at runtime +- If `mint::authority_seeds` is not provided, the generated code verifies `authority` is a transaction signer at runtime ## Related Documentation -- **`../rentfree.md`** - Full RentFree derive macro documentation -- **`../rentfree_program/`** - Program-level `#[rentfree_program]` macro +- **`../CLAUDE.md`** - Main entry point for sdk-libs/macros +- **`../light_program/`** - Program-level `#[light_program]` macro - **`../account/`** - Trait derives for data structs 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 0e2c4d992a..07a2df303e 100644 --- a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs +++ b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs @@ -10,7 +10,7 @@ use syn::{Expr, Ident, ItemStruct, Type}; use crate::{ light_pdas::{ light_account_keywords::{ - is_standalone_keyword, unknown_keyword_error, valid_keywords_for_type, + is_standalone_keyword, unknown_key_error, valid_keys_for_namespace, }, shared_utils::{extract_terminal_ident, is_constant_identifier}, }, @@ -144,8 +144,10 @@ pub struct ExtractedAccountsInfo { pub struct_name: Ident, pub pda_fields: Vec, pub token_fields: Vec, - /// True if struct has any #[light_account(init)] fields + /// True if struct has any #[light_account(init, mint::...)] fields pub has_light_mint_fields: bool, + /// True if struct has any #[light_account(init, associated_token::...)] fields + pub has_light_ata_fields: bool, } /// Extract rentfree field info from an Accounts struct @@ -161,6 +163,7 @@ pub fn extract_from_accounts_struct( let mut pda_fields = Vec::new(); let mut token_fields = Vec::new(); let mut has_light_mint_fields = false; + let mut has_light_ata_fields = false; for field in fields { let field_ident = match &field.ident { @@ -169,12 +172,15 @@ pub fn extract_from_accounts_struct( }; // Check for #[light_account(...)] attribute and determine its type - let (has_light_account_pda, has_light_account_mint) = + let (has_light_account_pda, has_light_account_mint, has_light_account_ata) = check_light_account_type(&field.attrs); if has_light_account_mint { has_light_mint_fields = true; } + if has_light_account_ata { + has_light_ata_fields = true; + } // Check for #[light_account(token, ...)] attribute let token_attr = extract_light_token_attr(&field.attrs, instruction_args)?; @@ -227,8 +233,12 @@ pub fn extract_from_accounts_struct( } } - // If no rentfree/light_mint fields found, return None - if pda_fields.is_empty() && token_fields.is_empty() && !has_light_mint_fields { + // If no rentfree/light_mint/ata fields found, return None + if pda_fields.is_empty() + && token_fields.is_empty() + && !has_light_mint_fields + && !has_light_ata_fields + { return Ok(None); } @@ -274,12 +284,19 @@ pub fn extract_from_accounts_struct( pda_fields, token_fields, has_light_mint_fields, + has_light_ata_fields, })) } -/// Check #[light_account(...)] attributes for PDA or mint type. -/// Returns (has_pda, has_mint) indicating which type was detected. -fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool) { +/// Check #[light_account(...)] attributes for PDA, mint, or ATA type. +/// Returns (has_pda, has_mint, has_ata) indicating which type was detected. +/// +/// Types: +/// - PDA: `#[light_account(init)]` only (no namespace prefix) +/// - Mint: `#[light_account(init, mint::...)]` +/// - Token: `#[light_account(init, token::...)]` or `#[light_account(token::...)]` +/// - ATA: `#[light_account(init, associated_token::...)]` or `#[light_account(associated_token::...)]` +fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool, bool) { for attr in attrs { if attr.path().is_ident("light_account") { // Parse the content to determine if it's init-only (PDA) or init+mint (Mint) @@ -288,26 +305,49 @@ fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool) { _ => continue, }; - // Single pass to check for both "init" and "mint" keywords - let mut has_mint = false; - let mut has_init = false; - for token in tokens { - if let proc_macro2::TokenTree::Ident(ident) = token { - if ident == "mint" { - has_mint = true; - } else if ident == "init" { - has_init = true; - } - } - } + let token_vec: Vec<_> = tokens.clone().into_iter().collect(); + + // Helper to check for a namespace prefix (e.g., "mint", "token", "associated_token") + let has_namespace_prefix = |namespace: &str| { + token_vec.windows(2).any(|window| { + matches!( + (&window[0], &window[1]), + ( + proc_macro2::TokenTree::Ident(ident), + proc_macro2::TokenTree::Punct(punct) + ) if ident == namespace && punct.as_char() == ':' + ) + }) + }; + + let has_mint_namespace = has_namespace_prefix("mint"); + let has_token_namespace = has_namespace_prefix("token"); + let has_ata_namespace = has_namespace_prefix("associated_token"); + + // Check for init keyword + let has_init = token_vec + .iter() + .any(|t| matches!(t, proc_macro2::TokenTree::Ident(ident) if ident == "init")); if has_init { - // If has mint, it's a mint field; otherwise it's a PDA - return (!has_mint, has_mint); + // If has mint namespace, it's a mint field + if has_mint_namespace { + return (false, true, false); + } + // If has associated_token namespace, it's an ATA field + if has_ata_namespace { + return (false, false, true); + } + // If has token namespace, it's NOT a PDA (handled separately) + if has_token_namespace { + return (false, false, false); + } + // Otherwise it's a plain PDA init + return (true, false, false); } } } - (false, false) + (false, false, false) } /// Parsed #[light_account(token, ...)] or #[light_account(associated_token, ...)] attribute @@ -320,12 +360,12 @@ struct LightTokenAttr { account_type: String, } -/// Extract #[light_account(token, ...)] attribute +/// Extract #[light_account(token::..., ...)] attribute /// Variant name is derived from field name, not specified in attribute /// Returns Err if the attribute exists but has malformed syntax /// /// Note: This function currently only handles `token` accounts, not `associated_token`. -/// Associated token accounts are handled differently (they use `owner` instead of `authority`). +/// Associated token accounts are handled differently (they use `authority` instead of `owner`). /// The ExtractedTokenSpec struct is designed for token accounts with authority seeds. fn extract_light_token_attr( attrs: &[syn::Attribute], @@ -338,14 +378,20 @@ fn extract_light_token_attr( _ => continue, }; - // Check if "token" keyword is present (without requiring "init") - // Note: associated_token is not handled here as it has different semantics - let has_token = tokens - .clone() - .into_iter() - .any(|t| matches!(&t, proc_macro2::TokenTree::Ident(ident) if ident == "token")); + // Check for token namespace (token::...) - new syntax + // Look for pattern: ident "token" followed by "::" + let token_vec: Vec<_> = tokens.clone().into_iter().collect(); + let has_token_namespace = token_vec.windows(2).any(|window| { + matches!( + (&window[0], &window[1]), + ( + proc_macro2::TokenTree::Ident(ident), + proc_macro2::TokenTree::Punct(punct) + ) if ident == "token" && punct.as_char() == ':' + ) + }); - if has_token { + if has_token_namespace { // Parse attribute content - propagate errors instead of swallowing them let parsed = parse_light_token_list(&tokens, instruction_args, "token")?; return Ok(Some(parsed)); @@ -355,8 +401,7 @@ fn extract_light_token_attr( Ok(None) } -/// Parse light_account(token, ...) or light_account(associated_token, ...) content -/// Uses shared keywords from light_account_keywords module for consistent validation. +/// Parse light_account(token::..., ...) content with namespace::key syntax fn parse_light_token_list( tokens: &proc_macro2::TokenStream, instruction_args: &InstructionArgSet, @@ -367,7 +412,7 @@ fn parse_light_token_list( // Capture instruction_args and account_type for use in closure let instruction_args = instruction_args.clone(); let account_type_owned = account_type.to_string(); - let valid_keys = valid_keywords_for_type(account_type); + let valid_keys = valid_keys_for_namespace(account_type); let parser = move |input: syn::parse::ParseStream| -> syn::Result { let mut authority_seeds = None; @@ -378,34 +423,87 @@ fn parse_light_token_list( let ident: Ident = input.parse()?; let ident_str = ident.to_string(); - // Check if it's a standalone keyword (init, token, associated_token) - if is_standalone_keyword(&ident_str) { - // Standalone keywords, continue parsing - } else if ident_str == "authority" { - // Parse authority = [...] - input.parse::()?; - let array: syn::ExprArray = input.parse()?; - let mut seeds = Vec::new(); - for elem in &array.elems { - if let Ok(seed) = classify_seed_expr(elem, &instruction_args) { - seeds.push(seed); - } + // Check for namespace::key syntax FIRST (before standalone keywords) + // because "token" can be both a standalone keyword and a namespace prefix + if input.peek(syn::Token![:]) { + // Namespace::key syntax (e.g., token::authority = [...]) + // Parse first colon + input.parse::()?; + // Parse second colon + if input.peek(syn::Token![:]) { + input.parse::()?; } - authority_seeds = Some(seeds); - } else if valid_keys.contains(&ident_str.as_str()) { - // Valid keyword for this account type (mint, owner, bump) - // Check if it has a value (= expr) or is shorthand - if input.peek(syn::Token![=]) { - input.parse::()?; - let _expr: syn::Expr = input.parse()?; + + let key: Ident = input.parse()?; + let key_str = key.to_string(); + + // Validate namespace matches expected account type + if ident_str != account_type_owned { + // Different namespace, skip (might be associated_token::) + // Just consume any value after = + if input.peek(syn::Token![=]) { + input.parse::()?; + let _expr: syn::Expr = input.parse()?; + } + } else { + // Validate key for this namespace + if !valid_keys.contains(&key_str.as_str()) { + return Err(syn::Error::new_spanned( + &key, + unknown_key_error(&account_type_owned, &key_str), + )); + } + + // Check if value follows + if input.peek(syn::Token![=]) { + input.parse::()?; + + if key_str == "authority" { + // Parse authority = [...] array + // The array is represented as a Group(Bracket) in proc_macro2 + // Use input.step to manually handle the Group + let array_content = input.step(|cursor| { + if let Some((group, _span, rest)) = + cursor.group(proc_macro2::Delimiter::Bracket) + { + Ok((group.token_stream(), rest)) + } else { + Err(cursor.error("expected bracketed array")) + } + })?; + + // Parse the array content + let elems: syn::punctuated::Punctuated = + syn::parse::Parser::parse2( + syn::punctuated::Punctuated::parse_terminated, + array_content, + )?; + let mut seeds = Vec::new(); + for elem in &elems { + if let Ok(seed) = classify_seed_expr(elem, &instruction_args) { + seeds.push(seed); + } + } + authority_seeds = Some(seeds); + } else { + // Other keys (mint, owner, bump) - just consume the value + let _expr: syn::Expr = input.parse()?; + } + } + // If no = follows for shorthand keys, it's fine - we don't need the value } - // If no = follows, it's shorthand syntax (e.g., `mint` means `mint = mint`) - // For seed_extraction, we just ignore the value as it's informational + } else if is_standalone_keyword(&ident_str) { + // Standalone keywords (init, token, associated_token, mint) + // Just continue - these don't require values } else { - // Unknown keyword - generate error using shared function + // Unknown standalone identifier (not a keyword, not namespace::key) return Err(syn::Error::new_spanned( &ident, - unknown_keyword_error(&ident_str, &account_type_owned), + format!( + "Unknown keyword `{}` in #[light_account(...)]. \ + Use namespaced syntax: `{}::authority = [...]`, `{}::mint`, etc.", + ident_str, account_type_owned, account_type_owned + ), )); } } else { @@ -414,8 +512,9 @@ fn parse_light_token_list( return Err(syn::Error::new( input.span(), format!( - "Expected keyword in #[light_account({}, ...)]. Valid keywords: init, {}, {}", - account_type_owned, account_type_owned, valid_kw_str + "Expected keyword in #[light_account(...)]. \ + Valid namespaced keys: {}::{{{}}}, or standalone: init", + account_type_owned, valid_kw_str ), )); } @@ -1076,4 +1175,74 @@ mod tests { assert!(args.contains("amount")); assert!(args.contains("flag")); } + + #[test] + fn test_check_light_account_type_mint_namespace() { + // Test that mint:: namespace is detected correctly + let attrs: Vec = vec![parse_quote!( + #[light_account(init, + mint::signer = mint_signer, + mint::authority = fee_payer, + mint::decimals = 6 + )] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA"); + assert!(has_mint, "Should be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); + } + + #[test] + fn test_check_light_account_type_pda_only() { + // Test that plain init (no mint::) is detected as PDA + let attrs: Vec = vec![parse_quote!( + #[light_account(init)] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(has_pda, "Should be detected as PDA"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); + } + + #[test] + fn test_check_light_account_type_token_namespace() { + // Test that token:: namespace is not detected as mint (it's neither PDA nor mint nor ATA) + let attrs: Vec = vec![parse_quote!( + #[light_account(token::authority = [b"auth"])] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA (no init)"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); + } + + #[test] + fn test_check_light_account_type_associated_token_init() { + // Test that associated_token:: with init is detected as ATA + let attrs: Vec = vec![parse_quote!( + #[light_account(init, + associated_token::authority = owner, + associated_token::mint = mint + )] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(has_ata, "Should be detected as ATA"); + } + + #[test] + fn test_check_light_account_type_token_init() { + // Test that token:: with init is NOT detected as PDA + let attrs: Vec = vec![parse_quote!( + #[light_account(init, + token::authority = [b"vault_auth"], + token::mint = mint + )] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); + } } diff --git a/sdk-libs/macros/src/light_pdas/accounts/derive.rs b/sdk-libs/macros/src/light_pdas/accounts/derive.rs index 80c74096cf..ad5bbbe5f9 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/derive.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/derive.rs @@ -71,7 +71,7 @@ mod tests { #[account(mut)] pub fee_payer: Signer<'info>, - #[light_account(init, token, authority = [b"authority"], mint = my_mint, owner = fee_payer)] + #[light_account(init, token::authority = [b"authority"], token::mint = my_mint, token::owner = fee_payer)] pub vault: Account<'info, CToken>, pub light_token_compressible_config: Account<'info, CompressibleConfig>, @@ -113,7 +113,7 @@ mod tests { #[account(mut)] pub fee_payer: Signer<'info>, - #[light_account(init, associated_token, owner = wallet, mint = my_mint)] + #[light_account(init, associated_token::authority = wallet, associated_token::mint = my_mint)] pub user_ata: Account<'info, CToken>, pub wallet: AccountInfo<'info>, @@ -149,8 +149,8 @@ mod tests { #[account(mut)] pub fee_payer: Signer<'info>, - // Mark-only: no init keyword - #[light_account(token, authority = [b"authority"])] + // Mark-only: no init keyword, type inferred from namespace + #[light_account(token::authority = [b"authority"])] pub vault: Account<'info, CToken>, } }; @@ -186,10 +186,10 @@ mod tests { #[account(mut)] pub fee_payer: Signer<'info>, - #[light_account(init, token, authority = [b"authority"], mint = my_mint, owner = fee_payer)] + #[light_account(init, token::authority = [b"authority"], token::mint = my_mint, token::owner = fee_payer)] pub vault: Account<'info, CToken>, - #[light_account(init, associated_token, owner = wallet, mint = my_mint)] + #[light_account(init, associated_token::authority = wallet, associated_token::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 e413258d68..af2baf95ae 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs @@ -5,6 +5,40 @@ //! - `#[light_account(init, mint, ...)]` - Light Mints //! - `#[light_account(token, ...)]` - Light token accounts //! +//! ## Syntax (Anchor-style namespace::key) +//! +//! All parameters require a namespace prefix matching the account type: +//! +//! ### Token Account +//! ```ignore +//! #[light_account(init, token, +//! token::authority = [VAULT_SEED, self.offer.key()], +//! token::mint = token_mint_a, +//! token::owner = authority, +//! token::bump = params.vault_bump +//! )] +//! ``` +//! +//! ### Associated Token Account +//! ```ignore +//! #[light_account(init, associated_token, +//! associated_token::authority = owner, +//! associated_token::mint = mint, +//! associated_token::bump = params.ata_bump +//! )] +//! ``` +//! +//! ### Mint +//! ```ignore +//! #[light_account(init, mint, +//! mint::signer = mint_signer, +//! mint::authority = authority, +//! mint::decimals = params.decimals, +//! mint::seeds = &[MINT_SIGNER_SEED, self.authority.key().as_ref()], +//! mint::bump = params.mint_signer_bump +//! )] +//! ``` +//! //! Note: Token fields are NOT processed here - they're handled by seed_extraction.rs //! in the light_program macro. This parser returns None for token fields. @@ -16,7 +50,8 @@ use syn::{ use super::mint::LightMintField; pub(super) use crate::light_pdas::account::seed_extraction::extract_account_inner_type; use crate::light_pdas::light_account_keywords::{ - is_shorthand_keyword, unknown_keyword_error, valid_keywords_for_type, + is_shorthand_key, is_standalone_keyword, missing_namespace_error, valid_keys_for_namespace, + validate_namespaced_key, }; // ============================================================================ @@ -33,6 +68,18 @@ pub enum LightAccountType { AssociatedToken, // `associated_token` keyword - for ATAs } +impl LightAccountType { + /// Get the namespace string for this account type. + pub fn namespace(&self) -> &'static str { + match self { + LightAccountType::Pda => "pda", + LightAccountType::Mint => "mint", + LightAccountType::Token => "token", + LightAccountType::AssociatedToken => "associated_token", + } + } +} + // ============================================================================ // Unified Parsed Result // ============================================================================ @@ -64,7 +111,7 @@ pub struct TokenAccountField { pub field_ident: Ident, /// True if `init` keyword is present (generate creation code) pub has_init: bool, - /// Authority seeds for the PDA owner (from authority = [...] parameter) + /// Authority seeds for the PDA owner (from token::authority = [...] parameter) /// Note: Seeds should NOT include the bump - it's auto-derived or passed via `bump` parameter pub authority_seeds: Vec, /// Mint reference (extracted from seeds or explicit parameter) @@ -75,36 +122,81 @@ pub struct TokenAccountField { pub bump: Option, } -/// A field marked with #[light_account([init,] ata, ...)] (Associated Token Account). +/// A field marked with #[light_account([init,] associated_token, ...)] (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) + /// Owner of the ATA (from associated_token::authority = ... parameter) + /// Note: User-facing is "authority" but maps to internal "owner" field pub owner: Expr, - /// Mint for the ATA (from mint = ... parameter) + /// Mint for the ATA (from associated_token::mint = ... parameter) pub mint: Expr, - /// Bump seed (from #[account(seeds = [...], bump)]) + /// Bump seed (from associated_token::bump = ...) pub bump: Option, } // ============================================================================ -// Custom Parser for #[light_account(init, [mint,] key = value, ...)] +// Custom Parser for #[light_account(init, [mint,] namespace::key = value, ...)] // ============================================================================ -/// Key-value pair in the attribute arguments. -struct KeyValue { +/// Namespaced key-value pair in the attribute arguments. +/// Syntax: `namespace::key = value` (e.g., `token::mint = token_mint`) +struct NamespacedKeyValue { + namespace: Ident, key: Ident, value: Expr, } -impl Parse for KeyValue { +impl Parse for NamespacedKeyValue { fn parse(input: ParseStream) -> syn::Result { + let namespace: Ident = input.parse()?; + input.parse::()?; let key: Ident = input.parse()?; - input.parse::()?; - let value: Expr = input.parse()?; - Ok(Self { key, value }) + + // Check for shorthand syntax (key alone without = value) + 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); + 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()? + } + } else { + // Shorthand: key alone means key = key + let namespace_str = namespace.to_string(); + let key_str = key.to_string(); + if is_shorthand_key(&namespace_str, &key_str) { + syn::parse_quote!(#key) + } else { + return Err(Error::new_spanned( + &key, + format!( + "`{}::{}` requires a value (e.g., `{}::{} = ...`)", + namespace_str, key_str, namespace_str, key_str + ), + )); + } + }; + + Ok(Self { + namespace, + key, + value, + }) } } @@ -116,26 +208,86 @@ struct LightAccountArgs { is_token: bool, /// The account type (Pda, Mint, etc.). account_type: LightAccountType, - /// Key-value pairs for additional arguments. - key_values: Vec, + /// Namespaced key-value pairs for additional arguments. + key_values: Vec, } impl Parse for LightAccountArgs { fn parse(input: ParseStream) -> syn::Result { - // First token must be `init`, `token`, or `associated_token` + // First token must be `init`, `token::`, `associated_token::`, or a namespaced key let first: Ident = input.parse()?; - // Handle `token` or `associated_token` as first argument (mark-only mode, no init) + // Handle mark-only mode: `token::key` or `associated_token::key` without `init` + // This allows: #[light_account(token::authority = [...])] + if input.peek(Token![::]) { + let account_type = infer_type_from_namespace(&first)?; + + // Parse the first namespaced key-value (we already have the namespace) + input.parse::()?; + let key: Ident = input.parse()?; + + let value = if input.peek(Token![=]) { + input.parse::()?; + if key == "authority" && input.peek(syn::token::Bracket) { + let content; + syn::bracketed!(content in input); + 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()? + } + } else { + let key_str = key.to_string(); + let namespace_str = first.to_string(); + if is_shorthand_key(&namespace_str, &key_str) { + syn::parse_quote!(#key) + } else { + return Err(Error::new_spanned( + &key, + format!( + "`{}::{}` requires a value (e.g., `{}::{} = ...`)", + namespace_str, key_str, namespace_str, key_str + ), + )); + } + }; + + let mut key_values = vec![NamespacedKeyValue { + namespace: first.clone(), + key, + value, + }]; + + // Parse remaining key-values + let remaining = parse_namespaced_key_values(input, account_type)?; + key_values.extend(remaining); + + return Ok(Self { + has_init: false, + is_token: true, // Skip in LightAccounts derive (for mark-only mode) + account_type, + key_values, + }); + } + + // Handle old-style standalone keywords (backward compatibility) if first == "token" || first == "associated_token" { let account_type = if first == "token" { LightAccountType::Token } else { LightAccountType::AssociatedToken }; - let key_values = parse_token_ata_key_values(input, &first)?; + let key_values = parse_namespaced_key_values(input, account_type)?; return Ok(Self { has_init: false, - is_token: true, // Skip in LightAccounts derive (for mark-only mode) + is_token: true, account_type, key_values, }); @@ -144,7 +296,7 @@ impl Parse for LightAccountArgs { if first != "init" { return Err(Error::new_spanned( &first, - "First argument to #[light_account] must be `init`, `token`, or `associated_token`", + "First argument to #[light_account] must be `init` or a namespaced key (e.g., `token::authority`)", )); } @@ -155,37 +307,61 @@ impl Parse for LightAccountArgs { while !input.is_empty() { input.parse::()?; - // Check if this is a type keyword (mint, token, associated_token) + if input.is_empty() { + break; + } + + // Check if this is a namespaced key (has `::` after ident) if input.peek(Ident) { let lookahead = input.fork(); let ident: Ident = lookahead.parse()?; - // 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 == "associated_token" { - input.parse::()?; // consume it - account_type = LightAccountType::AssociatedToken; - // Parse remaining associated_token-specific key-values - key_values = parse_token_ata_key_values(input, &ident)?; - break; + // If followed by `::`, infer type from namespace + if lookahead.peek(Token![::]) { + // Infer account type from namespace + let inferred_type = infer_type_from_namespace(&ident)?; + + // If this is the first namespaced key, set account type + if account_type == LightAccountType::Pda { + account_type = inferred_type; } + + // Parse this key-value and remaining ones + let kv: NamespacedKeyValue = input.parse()?; + key_values.push(kv); + + // Parse remaining key-values + let remaining = parse_namespaced_key_values(input, account_type)?; + key_values.extend(remaining); + break; } - } - // Otherwise it's a key-value pair - if !input.is_empty() { - let kv: KeyValue = input.parse()?; - key_values.push(kv); + // Check for explicit type keywords (backward compatibility) + if ident == "mint" { + input.parse::()?; // consume it + account_type = LightAccountType::Mint; + key_values = parse_namespaced_key_values(input, account_type)?; + break; + } else if ident == "token" { + input.parse::()?; // consume it + account_type = LightAccountType::Token; + key_values = parse_namespaced_key_values(input, account_type)?; + break; + } else if ident == "associated_token" { + input.parse::()?; // consume it + account_type = LightAccountType::AssociatedToken; + key_values = parse_namespaced_key_values(input, account_type)?; + break; + } + + // Old syntax - give helpful error + return Err(Error::new_spanned( + &ident, + format!( + "Unknown keyword `{}`. Use namespaced syntax like `token::authority` or `mint::signer`", + ident + ), + )); } } @@ -198,17 +374,32 @@ impl Parse for LightAccountArgs { } } -/// 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( +/// Infer account type from namespace identifier. +fn infer_type_from_namespace(namespace: &Ident) -> Result { + let ns = namespace.to_string(); + match ns.as_str() { + "token" => Ok(LightAccountType::Token), + "associated_token" => Ok(LightAccountType::AssociatedToken), + "mint" => Ok(LightAccountType::Mint), + _ => Err(Error::new_spanned( + namespace, + format!( + "Unknown namespace `{}`. Expected: token, associated_token, or mint", + ns + ), + )), + } +} + +/// Parse namespaced key-value pairs for token, associated_token, and mint attributes. +/// Syntax: `namespace::key = value` (e.g., `token::mint = token_mint`) +fn parse_namespaced_key_values( input: ParseStream, - account_type_name: &Ident, -) -> syn::Result> { + account_type: LightAccountType, +) -> syn::Result> { let mut key_values = Vec::new(); let mut seen_keys = std::collections::HashSet::new(); - let account_type_str = account_type_name.to_string(); - let valid_keys = valid_keywords_for_type(&account_type_str); + let expected_namespace = account_type.namespace(); while !input.is_empty() { input.parse::()?; @@ -217,62 +408,56 @@ fn parse_token_ata_key_values( break; } - let key: Ident = input.parse()?; - let key_str = key.to_string(); + // Check if this looks like an old-style non-namespaced key + let fork = input.fork(); + let maybe_key: Ident = fork.parse()?; - // Check for duplicate keys - if !seen_keys.insert(key_str.clone()) { + // If followed by `=` but not `::`, it's old syntax + if fork.peek(Token![=]) && !input.peek2(Token![::]) { + // Check if this is just a standalone keyword + if !is_standalone_keyword(&maybe_key.to_string()) { + return Err(Error::new_spanned( + &maybe_key, + missing_namespace_error(&maybe_key.to_string(), expected_namespace), + )); + } + } + + let kv: NamespacedKeyValue = input.parse()?; + + let namespace_str = kv.namespace.to_string(); + let key_str = kv.key.to_string(); + + // Validate namespace matches account type + if namespace_str != expected_namespace { return Err(Error::new_spanned( - &key, + &kv.namespace, format!( - "Duplicate key `{}` in #[light_account({}, ...)]. Each key can only appear once.", - key_str, - account_type_name + "Namespace `{}` doesn't match account type `{}`. Use `{}::{}` instead.", + namespace_str, expected_namespace, expected_namespace, key_str ), )); } - if !valid_keys.contains(&key_str.as_str()) { + // Check for duplicate keys + if !seen_keys.insert(key_str.clone()) { return Err(Error::new_spanned( - &key, - unknown_keyword_error(&key_str, &account_type_str), + &kv.key, + format!( + "Duplicate key `{}::{}` in #[light_account({}, ...)]. Each key can only appear once.", + namespace_str, + key_str, + expected_namespace + ), )); } - // 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()? - } - } else { - // Shorthand: key alone means key = key (for mint, owner, bump) - if is_shorthand_keyword(&key_str) { - syn::parse_quote!(#key) - } else { - return Err(Error::new_spanned( - &key, - format!("`{}` requires a value (e.g., `{} = ...`)", key_str, key_str), - )); - } - }; + // Validate key is valid for this namespace + if let Err(err_msg) = validate_namespaced_key(&namespace_str, &key_str) { + return Err(Error::new_spanned(&kv.key, err_msg)); + } - key_values.push(KeyValue { key, value }); + key_values.push(kv); } Ok(key_values) @@ -304,13 +489,13 @@ pub(super) fn parse_light_account_attr( // Return None so LightAccounts derive skips them // But still validate that required parameters are present if args.is_token && !args.has_init { - // For mark-only token, authority is required but mint/owner are NOT allowed + // For mark-only token, token::authority is required but token::mint/token::owner are NOT allowed if args.account_type == LightAccountType::Token { let has_authority = args.key_values.iter().any(|kv| kv.key == "authority"); if !has_authority { return Err(Error::new_spanned( attr, - "#[light_account(token, ...)] requires `authority = [...]` parameter", + "#[light_account(token, ...)] requires `token::authority = [...]` parameter", )); } // mint and owner are only for init mode @@ -320,8 +505,8 @@ pub(super) fn parse_light_account_attr( return Err(Error::new_spanned( &kv.key, format!( - "`{}` is only allowed with `init`. \ - For mark-only token, use: #[light_account(token, authority = [...])]", + "`token::{}` is only allowed with `init`. \ + For mark-only token, use: #[light_account(token, token::authority = [...])]", key ), )); @@ -370,13 +555,16 @@ pub(super) fn parse_light_account_attr( fn build_pda_field( field: &Field, field_ident: &Ident, - key_values: &[KeyValue], + key_values: &[NamespacedKeyValue], direct_proof_arg: &Option, ) -> Result { // Reject any key-value pairs - PDA only needs `init` // Tree info is always auto-fetched from CreateAccountsProof if !key_values.is_empty() { - let keys: Vec<_> = key_values.iter().map(|kv| kv.key.to_string()).collect(); + let keys: Vec<_> = key_values + .iter() + .map(|kv| format!("{}::{}", kv.namespace, kv.key)) + .collect(); return Err(Error::new_spanned( &key_values[0].key, format!( @@ -419,13 +607,27 @@ fn build_pda_field( }) } -/// Build a LightMintField from parsed key-value pairs. +/// Build a LightMintField from parsed namespaced key-value pairs. /// -/// # Arguments -/// * `direct_proof_arg` - If `Some`, use `.field` for defaults instead of `params.create_accounts_proof.field` +/// Mapping from new syntax to internal fields: +/// - `mint::signer` -> `mint_signer` +/// - `mint::authority` -> `authority` +/// - `mint::decimals` -> `decimals` +/// - `mint::seeds` -> `mint_seeds` +/// - `mint::bump` -> `mint_bump` +/// - `mint::freeze_authority` -> `freeze_authority` +/// - `mint::authority_seeds` -> `authority_seeds` +/// - `mint::authority_bump` -> `authority_bump` +/// - `mint::rent_payment` -> `rent_payment` +/// - `mint::write_top_up` -> `write_top_up` +/// - `mint::name` -> `name` +/// - `mint::symbol` -> `symbol` +/// - `mint::uri` -> `uri` +/// - `mint::update_authority` -> `update_authority` +/// - `mint::additional_metadata` -> `additional_metadata` fn build_mint_field( field_ident: &Ident, - key_values: &[KeyValue], + key_values: &[NamespacedKeyValue], attr: &syn::Attribute, direct_proof_arg: &Option, ) -> Result { @@ -452,35 +654,39 @@ fn build_mint_field( for kv in key_values { match kv.key.to_string().as_str() { - "mint_signer" => mint_signer = Some(kv.value.clone()), + // Required fields (new names) + "signer" => mint_signer = Some(kv.value.clone()), "authority" => authority = Some(kv.value.clone()), "decimals" => decimals = Some(kv.value.clone()), - "mint_seeds" => mint_seeds = Some(kv.value.clone()), - "address_tree_info" | "output_tree" => { - return Err(Error::new_spanned( - &kv.key, - "address_tree_info and output_tree are automatically sourced from CreateAccountsProof", - )); - } + "seeds" => mint_seeds = Some(kv.value.clone()), + + // Optional fields + "bump" => mint_bump = Some(kv.value.clone()), "freeze_authority" => { - freeze_authority = Some(expr_to_ident(&kv.value, "freeze_authority")?); + freeze_authority = Some(expr_to_ident(&kv.value, "mint::freeze_authority")?); } "authority_seeds" => authority_seeds = Some(kv.value.clone()), - "mint_bump" => mint_bump = Some(kv.value.clone()), "authority_bump" => authority_bump = Some(kv.value.clone()), "rent_payment" => rent_payment = Some(kv.value.clone()), "write_top_up" => write_top_up = Some(kv.value.clone()), + + // Metadata fields "name" => name = Some(kv.value.clone()), "symbol" => symbol = Some(kv.value.clone()), "uri" => uri = Some(kv.value.clone()), "update_authority" => { - update_authority = Some(expr_to_ident(&kv.value, "update_authority")?); + update_authority = Some(expr_to_ident(&kv.value, "mint::update_authority")?); } "additional_metadata" => additional_metadata = Some(kv.value.clone()), + other => { return Err(Error::new_spanned( &kv.key, - format!("Unknown argument `{other}` for mint"), + format!( + "Unknown key `mint::{}`. Allowed: {}", + other, + valid_keys_for_namespace("mint").join(", ") + ), )); } } @@ -490,25 +696,25 @@ fn build_mint_field( let mint_signer = mint_signer.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account(init, mint, ...)] requires `mint_signer`", + "#[light_account(init, mint, ...)] requires `mint::signer`", ) })?; let authority = authority.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account(init, mint, ...)] requires `authority`", + "#[light_account(init, mint, ...)] requires `mint::authority`", ) })?; let decimals = decimals.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account(init, mint, ...)] requires `decimals`", + "#[light_account(init, mint, ...)] requires `mint::decimals`", ) })?; let mint_seeds = mint_seeds.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account(init, mint, ...)] requires `mint_seeds`", + "#[light_account(init, mint, ...)] requires `mint::seeds`", ) })?; @@ -557,10 +763,16 @@ fn build_mint_field( }) } -/// Build a TokenAccountField from parsed key-value pairs. +/// Build a TokenAccountField from parsed namespaced key-value pairs. +/// +/// Mapping from new syntax to internal fields: +/// - `token::authority` -> `authority_seeds` +/// - `token::mint` -> `mint` +/// - `token::owner` -> `owner` +/// - `token::bump` -> `bump` fn build_token_account_field( field_ident: &Ident, - key_values: &[KeyValue], + key_values: &[NamespacedKeyValue], has_init: bool, attr: &syn::Attribute, ) -> Result { @@ -579,8 +791,8 @@ fn build_token_account_field( return Err(Error::new_spanned( &kv.key, format!( - "Unknown argument `{other}` for token. \ - Expected: authority, mint, owner, bump" + "Unknown key `token::{}`. Expected: authority, mint, owner, bump", + other ), )); } @@ -591,7 +803,7 @@ fn build_token_account_field( if authority.is_none() { return Err(Error::new_spanned( attr, - "#[light_account(token, ...)] requires `authority = [...]` parameter", + "#[light_account(token, ...)] requires `token::authority = [...]` parameter", )); } @@ -600,13 +812,13 @@ fn build_token_account_field( if mint.is_none() { return Err(Error::new_spanned( attr, - "#[light_account(init, token, ...)] requires `mint` parameter", + "#[light_account(init, token, ...)] requires `token::mint` parameter", )); } if owner.is_none() { return Err(Error::new_spanned( attr, - "#[light_account(init, token, ...)] requires `owner` parameter", + "#[light_account(init, token, ...)] requires `token::owner` parameter", )); } } @@ -617,7 +829,7 @@ fn build_token_account_field( if has_init && seeds.is_empty() { return Err(Error::new_spanned( auth_expr, - "Empty authority seeds `authority = []` not allowed for token account initialization. \ + "Empty authority seeds `token::authority = []` not allowed for token account initialization. \ Token accounts require at least one seed to derive the PDA owner.", )); } @@ -636,28 +848,33 @@ fn build_token_account_field( }) } -/// Build an AtaField from parsed key-value pairs. +/// Build an AtaField from parsed namespaced key-value pairs. +/// +/// Mapping from new syntax to internal fields: +/// - `associated_token::authority` -> `owner` (renamed to match Anchor's ATA naming) +/// - `associated_token::mint` -> `mint` +/// - `associated_token::bump` -> `bump` fn build_ata_field( field_ident: &Ident, - key_values: &[KeyValue], + key_values: &[NamespacedKeyValue], has_init: bool, attr: &syn::Attribute, ) -> Result { - let mut owner: Option = None; + let mut owner: Option = None; // from associated_token::authority let mut mint: Option = None; let mut bump: Option = None; for kv in key_values { match kv.key.to_string().as_str() { - "owner" => owner = Some(kv.value.clone()), + "authority" => owner = Some(kv.value.clone()), // authority -> owner "mint" => mint = Some(kv.value.clone()), "bump" => bump = Some(kv.value.clone()), other => { return Err(Error::new_spanned( &kv.key, format!( - "Unknown argument `{other}` in #[light_account(associated_token, ...)]. \ - Allowed: owner, mint, bump" + "Unknown key `associated_token::{}`. Allowed: authority, mint, bump", + other ), )); } @@ -668,13 +885,13 @@ fn build_ata_field( let owner = owner.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account([init,] associated_token, ...)] requires `owner` parameter", + "#[light_account([init,] associated_token, ...)] requires `associated_token::authority` parameter", ) })?; let mint = mint.ok_or_else(|| { Error::new_spanned( attr, - "#[light_account([init,] associated_token, ...)] requires `mint` parameter", + "#[light_account([init,] associated_token, ...)] requires `associated_token::mint` parameter", ) })?; @@ -740,7 +957,7 @@ fn validate_metadata_fields( if core_metadata_count > 0 && core_metadata_count < 3 { return Err(Error::new_spanned( attr, - "TokenMetadata requires all of `name`, `symbol`, and `uri` to be specified together", + "TokenMetadata requires all of `mint::name`, `mint::symbol`, and `mint::uri` to be specified together", )); } @@ -748,7 +965,7 @@ fn validate_metadata_fields( if (has_update_authority || has_additional_metadata) && core_metadata_count == 0 { return Err(Error::new_spanned( attr, - "`update_authority` and `additional_metadata` require `name`, `symbol`, and `uri` to also be specified", + "`mint::update_authority` and `mint::additional_metadata` require `mint::name`, `mint::symbol`, and `mint::uri` to also be specified", )); } @@ -804,29 +1021,23 @@ mod tests { fn test_parse_pda_tree_keywords_rejected() { // Tree keywords are no longer allowed - they're auto-fetched from CreateAccountsProof let field: syn::Field = parse_quote! { - #[light_account(init, address_tree_info = custom_tree, output_tree = custom_output)] + #[light_account(init, pda::address_tree_info = custom_tree)] pub record: Account<'info, MyRecord> }; let ident = field.ident.clone().unwrap(); let result = parse_light_account_attr(&field, &ident, &None); assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("Unexpected arguments") || err.contains("automatically sourced"), - "Expected error about rejected tree keywords, got: {}", - err - ); } #[test] fn test_parse_light_account_mint() { let field: syn::Field = parse_quote! { #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"test"] + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"] )] pub cmint: UncheckedAccount<'info> }; @@ -849,13 +1060,13 @@ mod tests { fn test_parse_light_account_mint_with_metadata() { let field: syn::Field = parse_quote! { #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"test"], - name = params.name.clone(), - symbol = params.symbol.clone(), - uri = params.uri.clone() + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"], + mint::name = params.name.clone(), + mint::symbol = params.symbol.clone(), + mint::uri = params.uri.clone() )] pub cmint: UncheckedAccount<'info> }; @@ -879,7 +1090,7 @@ mod tests { #[test] fn test_parse_light_account_missing_init() { let field: syn::Field = parse_quote! { - #[light_account(mint, decimals = 9)] + #[light_account(mint, mint::decimals = 9)] pub cmint: UncheckedAccount<'info> }; let ident = field.ident.clone().unwrap(); @@ -891,7 +1102,7 @@ mod tests { #[test] fn test_parse_light_account_mint_missing_required() { let field: syn::Field = parse_quote! { - #[light_account(init, mint, decimals = 9)] + #[light_account(init, mint, mint::decimals = 9)] pub cmint: UncheckedAccount<'info> }; let ident = field.ident.clone().unwrap(); @@ -904,11 +1115,11 @@ mod tests { fn test_parse_light_account_partial_metadata_fails() { let field: syn::Field = parse_quote! { #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"test"], - name = params.name.clone() + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"], + mint::name = params.name.clone() )] pub cmint: UncheckedAccount<'info> }; @@ -938,7 +1149,7 @@ mod tests { 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"])] + #[light_account(token, token::authority = [b"authority"])] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -951,7 +1162,7 @@ mod tests { #[test] fn test_parse_token_init_creates_field() { let field: syn::Field = parse_quote! { - #[light_account(init, token, authority = [b"authority"], mint = token_mint, owner = vault_authority)] + #[light_account(init, token, token::authority = [b"authority"], token::mint = token_mint, token::owner = vault_authority)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1010,7 +1221,7 @@ mod tests { fn test_parse_token_mark_only_rejects_mint() { // Mark-only token should not allow mint parameter let field: syn::Field = parse_quote! { - #[light_account(token, authority = [b"auth"], mint = token_mint)] + #[light_account(token, token::authority = [b"auth"], token::mint = token_mint)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1029,7 +1240,7 @@ mod tests { fn test_parse_token_mark_only_rejects_owner() { // Mark-only token should not allow owner parameter let field: syn::Field = parse_quote! { - #[light_account(token, authority = [b"auth"], owner = vault_authority)] + #[light_account(token, token::authority = [b"auth"], token::owner = vault_authority)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1048,7 +1259,7 @@ mod tests { fn test_parse_token_init_missing_mint_fails() { // Token init requires mint parameter let field: syn::Field = parse_quote! { - #[light_account(init, token, authority = [b"authority"], owner = vault_authority)] + #[light_account(init, token, token::authority = [b"authority"], token::owner = vault_authority)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1067,7 +1278,7 @@ mod tests { fn test_parse_token_init_missing_owner_fails() { // Token init requires owner parameter let field: syn::Field = parse_quote! { - #[light_account(init, token, authority = [b"authority"], mint = token_mint)] + #[light_account(init, token, token::authority = [b"authority"], token::mint = token_mint)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1082,31 +1293,6 @@ mod tests { ); } - #[test] - fn test_parse_mint_tree_keywords_rejected() { - // Tree keywords are no longer allowed for mint - they're auto-fetched from CreateAccountsProof - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"test"], - address_tree_info = custom_tree - )] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("automatically sourced"), - "Expected error about auto-sourced tree info, got: {}", - err - ); - } - // ======================================================================== // Associated Token Tests // ======================================================================== @@ -1115,7 +1301,7 @@ mod tests { 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(associated_token, owner = owner, mint = mint)] + #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1128,7 +1314,7 @@ mod tests { #[test] fn test_parse_associated_token_init_creates_field() { let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, owner = owner, mint = mint)] + #[light_account(init, associated_token, associated_token::authority = owner, associated_token::mint = mint)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1148,9 +1334,9 @@ mod tests { } #[test] - fn test_parse_associated_token_init_missing_owner_fails() { + fn test_parse_associated_token_init_missing_authority_fails() { let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, mint = mint)] + #[light_account(init, associated_token, associated_token::mint = mint)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1158,13 +1344,13 @@ mod tests { let result = parse_light_account_attr(&field, &ident, &None); assert!(result.is_err()); let err = result.err().unwrap().to_string(); - assert!(err.contains("owner")); + assert!(err.contains("authority")); } #[test] fn test_parse_associated_token_init_missing_mint_fails() { let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, owner = owner)] + #[light_account(init, associated_token, associated_token::authority = owner)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1178,7 +1364,7 @@ mod tests { #[test] fn test_parse_token_unknown_argument_fails() { let field: syn::Field = parse_quote! { - #[light_account(token, authority = [b"auth"], unknown = foo)] + #[light_account(token, token::authority = [b"auth"], token::unknown = foo)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1192,7 +1378,7 @@ mod tests { #[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)] + #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint, associated_token::unknown = foo)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1205,9 +1391,9 @@ mod tests { #[test] fn test_parse_associated_token_shorthand_syntax() { - // Test shorthand syntax: mint, owner, bump without = value + // Test shorthand syntax: mint, authority, bump without = value let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, owner, mint, bump)] + #[light_account(init, associated_token, associated_token::authority, associated_token::mint, associated_token::bump)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1229,9 +1415,9 @@ mod tests { #[test] fn test_parse_token_duplicate_key_fails() { - // F006: Duplicate keys should be rejected + // Duplicate keys should be rejected let field: syn::Field = parse_quote! { - #[light_account(token, authority = [b"auth1"], authority = [b"auth2"])] + #[light_account(token, token::authority = [b"auth1"], token::authority = [b"auth2"])] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1248,9 +1434,9 @@ mod tests { #[test] fn test_parse_associated_token_duplicate_key_fails() { - // F006: Duplicate keys in associated_token should also be rejected + // Duplicate keys in associated_token should also be rejected let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, owner = foo, owner = bar, mint)] + #[light_account(init, associated_token, associated_token::authority = foo, associated_token::authority = bar, associated_token::mint)] pub user_ata: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1267,9 +1453,9 @@ mod tests { #[test] fn test_parse_token_init_empty_authority_fails() { - // F007: Empty authority seeds with init should be rejected + // Empty authority seeds with init should be rejected let field: syn::Field = parse_quote! { - #[light_account(init, token, authority = [], mint = token_mint, owner = vault_authority)] + #[light_account(init, token, token::authority = [], token::mint = token_mint, token::owner = vault_authority)] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1286,9 +1472,9 @@ mod tests { #[test] fn test_parse_token_non_init_empty_authority_allowed() { - // F007: Empty authority seeds without init should be allowed (mark-only mode) + // Empty authority seeds without init should be allowed (mark-only mode) let field: syn::Field = parse_quote! { - #[light_account(token, authority = [])] + #[light_account(token, token::authority = [])] pub vault: Account<'info, CToken> }; let ident = field.ident.clone().unwrap(); @@ -1364,10 +1550,10 @@ mod tests { // the default address_tree_info should reference the proof arg directly. let field: syn::Field = parse_quote! { #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"test"] + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"] )] pub cmint: UncheckedAccount<'info> }; @@ -1432,10 +1618,10 @@ mod tests { // Test token with explicit bump parameter let field: syn::Field = parse_quote! { #[light_account(init, token, - authority = [b"vault", self.offer.key()], - mint = token_mint, - owner = vault_authority, - bump = params.vault_bump + token::authority = [b"vault", self.offer.key()], + token::mint = token_mint, + token::owner = vault_authority, + token::bump = params.vault_bump )] pub vault: Account<'info, CToken> }; @@ -1465,9 +1651,9 @@ mod tests { // Test token without bump (backwards compatible - bump will be auto-derived) let field: syn::Field = parse_quote! { #[light_account(init, token, - authority = [b"vault", self.offer.key()], - mint = token_mint, - owner = vault_authority + token::authority = [b"vault", self.offer.key()], + token::mint = token_mint, + token::owner = vault_authority )] pub vault: Account<'info, CToken> }; @@ -1497,14 +1683,14 @@ mod tests { #[test] fn test_parse_mint_with_mint_bump() { - // Test mint with explicit mint_bump parameter + // Test mint with explicit mint::bump parameter let field: syn::Field = parse_quote! { #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint"], - mint_bump = params.mint_bump + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint"], + mint::bump = params.mint_bump )] pub cmint: UncheckedAccount<'info> }; @@ -1513,7 +1699,7 @@ mod tests { let result = parse_light_account_attr(&field, &ident, &None); assert!( result.is_ok(), - "Should parse successfully with mint_bump parameter" + "Should parse successfully with mint::bump parameter" ); let result = result.unwrap(); assert!(result.is_some()); @@ -1535,12 +1721,12 @@ mod tests { // Test mint with authority_seeds and authority_bump let field: syn::Field = parse_quote! { #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint"], - authority_seeds = &[b"auth"], - authority_bump = params.auth_bump + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint"], + mint::authority_seeds = &[b"auth"], + mint::authority_bump = params.auth_bump )] pub cmint: UncheckedAccount<'info> }; @@ -1575,11 +1761,11 @@ mod tests { // Test mint without bump parameters (backwards compatible - bumps will be auto-derived) let field: syn::Field = parse_quote! { #[light_account(init, mint, - mint_signer = mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[b"mint"], - authority_seeds = &[b"auth"] + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint"], + mint::authority_seeds = &[b"auth"] )] pub cmint: UncheckedAccount<'info> }; @@ -1615,13 +1801,13 @@ mod tests { #[test] fn test_parse_token_bump_shorthand_syntax() { - // Test token with bump shorthand syntax (bump = bump) + // Test token with bump shorthand syntax (token::bump = bump) let field: syn::Field = parse_quote! { #[light_account(init, token, - authority = [b"vault"], - mint = token_mint, - owner = vault_authority, - bump + token::authority = [b"vault"], + token::mint = token_mint, + token::owner = vault_authority, + token::bump )] pub vault: Account<'info, CToken> }; @@ -1645,4 +1831,46 @@ mod tests { _ => panic!("Expected TokenAccount field"), } } + + // ======================================================================== + // Namespace Validation Tests + // ======================================================================== + + #[test] + fn test_parse_wrong_namespace_fails() { + // Using mint:: namespace with token account type should fail + let field: syn::Field = parse_quote! { + #[light_account(token, mint::authority = [b"auth"])] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); + } + + #[test] + fn test_old_syntax_gives_helpful_error() { + // Old syntax without namespace should give helpful migration error + let field: syn::Field = parse_quote! { + #[light_account(init, mint, authority = some_authority)] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("Missing namespace prefix") || err.contains("mint::authority"), + "Expected helpful migration error, got: {}", + err + ); + } } diff --git a/sdk-libs/macros/src/light_pdas/light_account_keywords.rs b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs index 712f0f08c2..59185b9af1 100644 --- a/sdk-libs/macros/src/light_pdas/light_account_keywords.rs +++ b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs @@ -1,23 +1,66 @@ //! Shared keyword definitions for `#[light_account(...)]` attribute parsing. //! //! This module provides a single source of truth for valid keywords used in -//! `#[light_account(...)]` attributes across both: -//! - `accounts/light_account.rs` - Used by `#[derive(LightAccounts)]` -//! - `account/seed_extraction.rs` - Used by `#[light_program]` +//! `#[light_account(...)]` attributes. Keywords use Anchor-style `namespace::key` syntax. +//! +//! ## Syntax +//! +//! All attribute parameters (except type markers) require a namespace prefix: +//! - `token::authority`, `token::mint`, `token::owner`, `token::bump` +//! - `associated_token::authority`, `associated_token::mint`, `associated_token::bump` +//! - `mint::signer`, `mint::authority`, `mint::decimals`, `mint::seeds`, etc. +//! +//! ## Example +//! +//! ```ignore +//! #[light_account(init, token, +//! token::authority = [VAULT_SEED, self.offer.key()], +//! token::mint = token_mint_a, +//! token::owner = authority, +//! token::bump = params.vault_bump +//! )] +//! pub vault: UncheckedAccount<'info>, +//! ``` + +/// Valid keys for `token::` namespace in `#[light_account(token, ...)]` attributes. +/// These map to the TokenAccountField struct. +pub const TOKEN_NAMESPACE_KEYS: &[&str] = &["authority", "mint", "owner", "bump"]; -/// Valid keywords for `#[light_account(token, ...)]` attributes. -pub const TOKEN_KEYWORDS: &[&str] = &["authority", "mint", "owner", "bump"]; +/// Valid keys for `associated_token::` namespace in `#[light_account(associated_token, ...)]`. +/// Note: `authority` is the user-facing name (maps internally to `owner` in AtaField). +pub const ASSOCIATED_TOKEN_NAMESPACE_KEYS: &[&str] = &["authority", "mint", "bump"]; -/// Valid keywords for `#[light_account(associated_token, ...)]` attributes. -pub const ASSOCIATED_TOKEN_KEYWORDS: &[&str] = &["owner", "mint", "bump"]; +/// Valid keys for `mint::` namespace in `#[light_account(init, mint, ...)]` attributes. +pub const MINT_NAMESPACE_KEYS: &[&str] = &[ + "signer", + "authority", + "decimals", + "seeds", + "bump", + "freeze_authority", + "authority_seeds", + "authority_bump", + "rent_payment", + "write_top_up", + "name", + "symbol", + "uri", + "update_authority", + "additional_metadata", +]; /// Standalone keywords that don't require a value (flags). /// These can appear as bare identifiers without `= value`. -pub const STANDALONE_KEYWORDS: &[&str] = &["init", "token", "associated_token"]; +pub const STANDALONE_KEYWORDS: &[&str] = &["init", "token", "associated_token", "mint"]; -/// Keywords that support shorthand syntax (key alone means key = key). -/// For example, `mint` alone is equivalent to `mint = mint`. -pub const SHORTHAND_KEYWORDS: &[&str] = &["mint", "owner", "bump"]; +/// Keywords that support shorthand syntax within their namespace. +/// For example, `token::mint` alone is equivalent to `token::mint = mint`. +/// Maps namespace -> list of shorthand-eligible keys +pub const SHORTHAND_KEYS_BY_NAMESPACE: &[(&str, &[&str])] = &[ + ("token", &["mint", "owner", "bump"]), + ("associated_token", &["authority", "mint", "bump"]), + // mint namespace does not support shorthand - values are typically expressions +]; /// Check if a keyword is a standalone flag (doesn't require a value). #[inline] @@ -25,42 +68,102 @@ pub fn is_standalone_keyword(keyword: &str) -> bool { STANDALONE_KEYWORDS.contains(&keyword) } -/// Check if a keyword supports shorthand syntax. +/// Check if a key supports shorthand syntax within a given namespace. +/// Shorthand means the key can appear without `= value` and defaults to `key = key`. #[inline] -pub fn is_shorthand_keyword(keyword: &str) -> bool { - SHORTHAND_KEYWORDS.contains(&keyword) +pub fn is_shorthand_key(namespace: &str, key: &str) -> bool { + for (ns, keys) in SHORTHAND_KEYS_BY_NAMESPACE { + if *ns == namespace { + return keys.contains(&key); + } + } + false } -/// Get the valid keywords for a given account type. +/// Get the valid keys for a given namespace. /// /// # Arguments -/// * `account_type` - Either "token" or "associated_token" +/// * `namespace` - One of "token", "associated_token", or "mint" /// /// # Returns -/// A slice of valid keyword strings for the account type. -pub fn valid_keywords_for_type(account_type: &str) -> &'static [&'static str] { - match account_type { - "token" => TOKEN_KEYWORDS, - "associated_token" => ASSOCIATED_TOKEN_KEYWORDS, +/// A slice of valid key strings for the namespace. +pub fn valid_keys_for_namespace(namespace: &str) -> &'static [&'static str] { + match namespace { + "token" => TOKEN_NAMESPACE_KEYS, + "associated_token" => ASSOCIATED_TOKEN_NAMESPACE_KEYS, + "mint" => MINT_NAMESPACE_KEYS, _ => &[], } } -/// Generate an error message for an unknown keyword. +/// Validate a key within a namespace. +/// +/// # Arguments +/// * `namespace` - The namespace (e.g., "token", "mint") +/// * `key` - The key within that namespace +/// +/// # Returns +/// `Ok(())` if valid, `Err(error_message)` if invalid. +pub fn validate_namespaced_key(namespace: &str, key: &str) -> Result<(), String> { + let valid_keys = valid_keys_for_namespace(namespace); + + if valid_keys.is_empty() { + return Err(format!( + "Unknown namespace `{}`. Expected: token, associated_token, or mint", + namespace + )); + } + + if !valid_keys.contains(&key) { + return Err(format!( + "Unknown key `{}` in `{}::` namespace. Allowed: {}", + key, + namespace, + valid_keys.join(", ") + )); + } + + Ok(()) +} + +/// Generate an error message for an unknown key within a namespace. /// /// # Arguments -/// * `keyword` - The unknown keyword that was encountered -/// * `account_type` - The account type being parsed ("token" or "associated_token") +/// * `namespace` - The namespace (e.g., "token", "mint") +/// * `key` - The unknown key that was encountered /// /// # Returns /// A formatted error message string. -pub fn unknown_keyword_error(keyword: &str, account_type: &str) -> String { - let valid = valid_keywords_for_type(account_type); +pub fn unknown_key_error(namespace: &str, key: &str) -> String { + let valid = valid_keys_for_namespace(namespace); + if valid.is_empty() { + format!( + "Unknown namespace `{}`. Expected: token, associated_token, or mint", + namespace + ) + } else { + format!( + "Unknown key `{}` in #[light_account({}, ...)]. Allowed for `{}::`: {}", + key, + namespace, + namespace, + valid.join(", ") + ) + } +} + +/// Generate an error message for a missing namespace prefix. +/// +/// # Arguments +/// * `key` - The key that's missing a namespace prefix +/// * `account_type` - The account type context (e.g., "token", "mint") +/// +/// # Returns +/// A formatted error message string with suggestions. +pub fn missing_namespace_error(key: &str, account_type: &str) -> String { format!( - "Unknown argument `{}` in #[light_account({}, ...)]. Allowed: {}", - keyword, - account_type, - valid.join(", ") + "Missing namespace prefix for `{}`. Use `{}::{}` instead of just `{}`", + key, account_type, key, key ) } @@ -69,21 +172,38 @@ mod tests { use super::*; #[test] - fn test_token_keywords() { - assert!(TOKEN_KEYWORDS.contains(&"authority")); - assert!(TOKEN_KEYWORDS.contains(&"mint")); - assert!(TOKEN_KEYWORDS.contains(&"owner")); - assert!(TOKEN_KEYWORDS.contains(&"bump")); // bump is now a valid token keyword - assert!(!TOKEN_KEYWORDS.contains(&"unknown")); + fn test_token_namespace_keys() { + assert!(TOKEN_NAMESPACE_KEYS.contains(&"authority")); + assert!(TOKEN_NAMESPACE_KEYS.contains(&"mint")); + assert!(TOKEN_NAMESPACE_KEYS.contains(&"owner")); + assert!(TOKEN_NAMESPACE_KEYS.contains(&"bump")); + assert!(!TOKEN_NAMESPACE_KEYS.contains(&"unknown")); } #[test] - fn test_ata_keywords() { - assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"owner")); - assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"mint")); - assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"bump")); - assert!(!ASSOCIATED_TOKEN_KEYWORDS.contains(&"authority")); - assert!(!ASSOCIATED_TOKEN_KEYWORDS.contains(&"unknown")); + fn test_associated_token_namespace_keys() { + assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"authority")); + assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"mint")); + assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"bump")); + assert!(!ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"owner")); // renamed to authority + assert!(!ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"unknown")); + } + + #[test] + fn test_mint_namespace_keys() { + assert!(MINT_NAMESPACE_KEYS.contains(&"signer")); // renamed from mint_signer + assert!(MINT_NAMESPACE_KEYS.contains(&"authority")); + assert!(MINT_NAMESPACE_KEYS.contains(&"decimals")); + assert!(MINT_NAMESPACE_KEYS.contains(&"seeds")); // renamed from mint_seeds + assert!(MINT_NAMESPACE_KEYS.contains(&"bump")); // renamed from mint_bump + assert!(MINT_NAMESPACE_KEYS.contains(&"freeze_authority")); + assert!(MINT_NAMESPACE_KEYS.contains(&"authority_seeds")); + assert!(MINT_NAMESPACE_KEYS.contains(&"authority_bump")); + assert!(MINT_NAMESPACE_KEYS.contains(&"name")); + assert!(MINT_NAMESPACE_KEYS.contains(&"symbol")); + assert!(MINT_NAMESPACE_KEYS.contains(&"uri")); + assert!(MINT_NAMESPACE_KEYS.contains(&"update_authority")); + assert!(MINT_NAMESPACE_KEYS.contains(&"additional_metadata")); } #[test] @@ -91,52 +211,72 @@ mod tests { assert!(is_standalone_keyword("init")); assert!(is_standalone_keyword("token")); assert!(is_standalone_keyword("associated_token")); + assert!(is_standalone_keyword("mint")); assert!(!is_standalone_keyword("authority")); - assert!(!is_standalone_keyword("mint")); } #[test] - fn test_shorthand_keywords() { - assert!(is_shorthand_keyword("mint")); - assert!(is_shorthand_keyword("owner")); - assert!(is_shorthand_keyword("bump")); - assert!(!is_shorthand_keyword("authority")); - assert!(!is_shorthand_keyword("init")); + fn test_shorthand_keys() { + // token namespace + assert!(is_shorthand_key("token", "mint")); + assert!(is_shorthand_key("token", "owner")); + assert!(is_shorthand_key("token", "bump")); + assert!(!is_shorthand_key("token", "authority")); // authority requires seeds array + + // associated_token namespace + assert!(is_shorthand_key("associated_token", "authority")); + assert!(is_shorthand_key("associated_token", "mint")); + assert!(is_shorthand_key("associated_token", "bump")); + + // mint namespace - no shorthand + assert!(!is_shorthand_key("mint", "signer")); + assert!(!is_shorthand_key("mint", "authority")); } #[test] - fn test_valid_keywords_for_type() { - let token_kw = valid_keywords_for_type("token"); - assert_eq!(token_kw, TOKEN_KEYWORDS); + fn test_valid_keys_for_namespace() { + let token_kw = valid_keys_for_namespace("token"); + assert_eq!(token_kw, TOKEN_NAMESPACE_KEYS); + + let ata_kw = valid_keys_for_namespace("associated_token"); + assert_eq!(ata_kw, ASSOCIATED_TOKEN_NAMESPACE_KEYS); - let ata_kw = valid_keywords_for_type("associated_token"); - assert_eq!(ata_kw, ASSOCIATED_TOKEN_KEYWORDS); + let mint_kw = valid_keys_for_namespace("mint"); + assert_eq!(mint_kw, MINT_NAMESPACE_KEYS); - let unknown_kw = valid_keywords_for_type("unknown"); + let unknown_kw = valid_keys_for_namespace("unknown"); assert!(unknown_kw.is_empty()); } #[test] - fn test_cross_validation() { - // mint and owner are valid for both token and ATA - assert!(TOKEN_KEYWORDS.contains(&"mint")); - assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"mint")); - assert!(TOKEN_KEYWORDS.contains(&"owner")); - assert!(ASSOCIATED_TOKEN_KEYWORDS.contains(&"owner")); + fn test_validate_namespaced_key() { + // Valid keys + assert!(validate_namespaced_key("token", "authority").is_ok()); + assert!(validate_namespaced_key("token", "mint").is_ok()); + assert!(validate_namespaced_key("associated_token", "authority").is_ok()); + assert!(validate_namespaced_key("mint", "signer").is_ok()); + assert!(validate_namespaced_key("mint", "decimals").is_ok()); + + // Invalid keys + assert!(validate_namespaced_key("token", "invalid").is_err()); + assert!(validate_namespaced_key("unknown_namespace", "key").is_err()); } #[test] - fn test_unknown_keyword_error() { - let error = unknown_keyword_error("unknown_key", "token"); - assert!(error.contains("unknown_key")); + fn test_unknown_key_error() { + let error = unknown_key_error("token", "invalid"); + assert!(error.contains("invalid")); assert!(error.contains("token")); assert!(error.contains("authority")); - assert!(error.contains("mint")); - assert!(error.contains("owner")); - let error = unknown_keyword_error("bad", "associated_token"); - assert!(error.contains("bad")); - assert!(error.contains("associated_token")); - assert!(error.contains("bump")); + let error = unknown_key_error("unknown", "key"); + assert!(error.contains("Unknown namespace")); + } + + #[test] + fn test_missing_namespace_error() { + let error = missing_namespace_error("authority", "token"); + assert!(error.contains("token::authority")); + assert!(error.contains("Missing namespace prefix")); } } diff --git a/sdk-libs/macros/src/light_pdas/program/instructions.rs b/sdk-libs/macros/src/light_pdas/program/instructions.rs index 9a6ef4ca3f..79f2682c7e 100644 --- a/sdk-libs/macros/src/light_pdas/program/instructions.rs +++ b/sdk-libs/macros/src/light_pdas/program/instructions.rs @@ -29,6 +29,7 @@ use crate::{ /// Orchestrates all code generation for the rentfree module. #[inline(never)] +#[allow(clippy::too_many_arguments)] fn codegen( module: &mut ItemMod, account_types: Vec, @@ -37,6 +38,7 @@ fn codegen( instruction_data: Vec, crate_ctx: &super::crate_context::CrateContext, has_mint_fields: bool, + has_ata_fields: bool, ) -> Result { let content = match module.content.as_mut() { Some(content) => content, @@ -337,15 +339,16 @@ fn codegen( let has_pda_seeds = pda_seeds.as_ref().map(|p| !p.is_empty()).unwrap_or(false); let has_token_seeds = token_seeds.as_ref().map(|t| !t.is_empty()).unwrap_or(false); - let instruction_variant = match (has_pda_seeds, has_token_seeds, has_mint_fields) { - (true, true, _) => InstructionVariant::Mixed, - (true, false, _) => InstructionVariant::PdaOnly, - (false, true, _) => InstructionVariant::TokenOnly, - (false, false, true) => InstructionVariant::MintOnly, - (false, false, false) => { + let instruction_variant = match (has_pda_seeds, has_token_seeds, has_mint_fields, has_ata_fields) { + (true, true, _, _) => InstructionVariant::Mixed, + (true, false, _, _) => InstructionVariant::PdaOnly, + (false, true, _, _) => InstructionVariant::TokenOnly, + (false, false, true, _) => InstructionVariant::MintOnly, + (false, false, false, true) => InstructionVariant::AtaOnly, + (false, false, false, false) => { return Err(macro_error!( module, - "No #[light_account(init)], #[light_account(init, mint)], or #[light_account(token)] fields found.\n\ + "No #[light_account(init)], #[light_account(init, mint::...)], #[light_account(init, associated_token::...)], or #[light_account(token::...)] fields found.\n\ At least one light account field must be provided." )) } @@ -593,6 +596,7 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result = Vec::new(); let mut rentfree_struct_names = std::collections::HashSet::new(); let mut has_any_mint_fields = false; + let mut has_any_ata_fields = false; for item_struct in crate_ctx.structs_with_derive("Accounts") { // Parse #[instruction(...)] attribute to get instruction arg names @@ -602,6 +606,7 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result Result Result { pub lp_mint_signer: UncheckedAccount<'info>, // TODO: check where the cpi gets the seeds from #[account(mut)] - #[light_account(init, mint, - mint_signer = lp_mint_signer, - authority = authority, - decimals = 9, - mint_seeds = &[POOL_LP_MINT_SIGNER_SEED, self.pool_state.to_account_info().key.as_ref()], - mint_bump = params.lp_mint_signer_bump, - authority_seeds = &[AUTH_SEED.as_bytes()], - authority_bump = params.authority_bump + #[light_account(init, + mint::signer = lp_mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[POOL_LP_MINT_SIGNER_SEED, self.pool_state.to_account_info().key.as_ref()], + mint::bump = params.lp_mint_signer_bump, + mint::authority_seeds = &[AUTH_SEED.as_bytes()], + mint::authority_bump = params.authority_bump )] pub lp_mint: UncheckedAccount<'info>, @@ -114,7 +114,7 @@ pub struct InitializePool<'info> { ], bump, )] - #[light_account(token, authority = [AUTH_SEED.as_bytes()])] + #[light_account(token::authority = [AUTH_SEED.as_bytes()])] pub token_0_vault: UncheckedAccount<'info>, #[account( @@ -126,7 +126,7 @@ pub struct InitializePool<'info> { ], bump, )] - #[light_account(token, authority = [AUTH_SEED.as_bytes()])] + #[light_account(token::authority = [AUTH_SEED.as_bytes()])] pub token_1_vault: UncheckedAccount<'info>, #[account( diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs index a088c5e591..02b0c524b4 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs @@ -71,12 +71,12 @@ pub struct CreatePdasAndMintAuto<'info> { /// CHECK: Initialized by mint_action #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer, - authority = mint_authority, - decimals = 9, - mint_seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_bump + #[light_account(init, + mint::signer = mint_signer, + mint::authority = mint_authority, + mint::decimals = 9, + mint::seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_bump )] pub mint: UncheckedAccount<'info>, @@ -86,7 +86,7 @@ pub struct CreatePdasAndMintAuto<'info> { seeds = [VAULT_SEED, mint.key().as_ref()], bump, )] - #[light_account(token, authority = [b"vault_authority"])] + #[light_account(token::authority = [b"vault_authority"])] pub vault: UncheckedAccount<'info>, /// CHECK: PDA used as vault owner @@ -157,23 +157,23 @@ pub struct CreateTwoMints<'info> { /// CHECK: Initialized by mint_action - first mint #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer_a, - authority = fee_payer, - decimals = 6, - mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_a_bump + #[light_account(init, + mint::signer = mint_signer_a, + mint::authority = fee_payer, + mint::decimals = 6, + mint::seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_a_bump )] pub cmint_a: UncheckedAccount<'info>, /// CHECK: Initialized by mint_action - second mint #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer_b, - authority = fee_payer, - decimals = 9, - mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_b_bump + #[light_account(init, + mint::signer = mint_signer_b, + mint::authority = fee_payer, + mint::decimals = 9, + mint::seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_b_bump )] pub cmint_b: UncheckedAccount<'info>, @@ -242,34 +242,34 @@ pub struct CreateThreeMints<'info> { /// CHECK: Initialized by light_mint CPI #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer_a, - authority = fee_payer, - decimals = 6, - mint_seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_a_bump + #[light_account(init, + mint::signer = mint_signer_a, + mint::authority = fee_payer, + mint::decimals = 6, + mint::seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_a_bump )] pub cmint_a: UncheckedAccount<'info>, /// CHECK: Initialized by light_mint CPI #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer_b, - authority = fee_payer, - decimals = 8, - mint_seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_b_bump + #[light_account(init, + mint::signer = mint_signer_b, + mint::authority = fee_payer, + mint::decimals = 8, + mint::seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_b_bump )] pub cmint_b: UncheckedAccount<'info>, /// CHECK: Initialized by light_mint CPI #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer_c, - authority = fee_payer, - decimals = 9, - mint_seeds = &[MINT_SIGNER_C_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_c_bump + #[light_account(init, + mint::signer = mint_signer_c, + mint::authority = fee_payer, + mint::decimals = 9, + mint::seeds = &[MINT_SIGNER_C_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_c_bump )] pub cmint_c: UncheckedAccount<'info>, @@ -327,17 +327,17 @@ pub struct CreateMintWithMetadata<'info> { /// CHECK: Initialized by light_mint CPI with metadata #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer, - authority = fee_payer, - decimals = 9, - mint_seeds = &[METADATA_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_bump, - name = params.name.clone(), - symbol = params.symbol.clone(), - uri = params.uri.clone(), - update_authority = authority, - additional_metadata = params.additional_metadata.clone() + #[light_account(init, + mint::signer = mint_signer, + mint::authority = fee_payer, + mint::decimals = 9, + mint::seeds = &[METADATA_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_bump, + mint::name = params.name.clone(), + mint::symbol = params.symbol.clone(), + mint::uri = params.uri.clone(), + mint::update_authority = authority, + mint::additional_metadata = params.additional_metadata.clone() )] pub cmint: UncheckedAccount<'info>, 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 5cc8171ce6..4fa61c9c28 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 @@ -35,7 +35,7 @@ pub struct D10SingleAta<'info> { /// ATA account - macro should generate creation code. #[account(mut)] - #[light_account(init, associated_token, owner = d10_ata_owner, mint = d10_ata_mint, bump = params.ata_bump)] + #[light_account(init, associated_token::authority = d10_ata_owner, associated_token::mint = d10_ata_mint, associated_token::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/d10_token_accounts/single_vault.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d10_token_accounts/single_vault.rs index f9c834afae..a555f17008 100644 --- 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 @@ -47,7 +47,7 @@ pub struct D10SingleVault<'info> { seeds = [D10_SINGLE_VAULT_SEED, d10_mint.key().as_ref()], bump, )] - #[light_account(init, token, authority = [D10_SINGLE_VAULT_SEED, self.d10_mint.key()], mint = d10_mint, owner = d10_vault_authority, bump = params.vault_bump)] + #[light_account(init, token::authority = [D10_SINGLE_VAULT_SEED, self.d10_mint.key()], token::mint = d10_mint, token::owner = d10_vault_authority, token::bump = params.vault_bump)] pub d10_single_vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/all.rs index 79c0e4c27b..9402e4adf0 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/all.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/all.rs @@ -55,7 +55,7 @@ pub struct D5AllMarkers<'info> { seeds = [D5_ALL_VAULT_SEED, mint.key().as_ref()], bump, )] - #[light_account(token, authority = [D5_ALL_AUTH_SEED])] + #[light_account(token::authority = [D5_ALL_AUTH_SEED])] pub d5_all_vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/light_token.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/light_token.rs index 75cad9494d..ffd09e7599 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/light_token.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d5_markers/light_token.rs @@ -38,7 +38,7 @@ pub struct D5LightToken<'info> { seeds = [D5_VAULT_SEED, mint.key().as_ref()], bump, )] - #[light_account(token, authority = [D5_VAULT_AUTH_SEED])] + #[light_account(token::authority = [D5_VAULT_AUTH_SEED])] pub d5_token_vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/all.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/all.rs index ebb5e296d6..36135deb37 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/all.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/all.rs @@ -55,7 +55,7 @@ pub struct D7AllNames<'info> { seeds = [D7_ALL_VAULT_SEED, mint.key().as_ref()], bump, )] - #[light_account(token, authority = [D7_ALL_AUTH_SEED])] + #[light_account(token::authority = [D7_ALL_AUTH_SEED])] pub d7_all_vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/light_token_config.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/light_token_config.rs index 79903b0216..b17aa52790 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/light_token_config.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instructions/d7_infra_names/light_token_config.rs @@ -36,7 +36,7 @@ pub struct D7LightTokenConfig<'info> { seeds = [D7_LIGHT_TOKEN_VAULT_SEED, mint.key().as_ref()], bump, )] - #[light_account(token, authority = [D7_LIGHT_TOKEN_AUTH_SEED])] + #[light_account(token::authority = [D7_LIGHT_TOKEN_AUTH_SEED])] pub d7_light_token_vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/single-ata-test/src/lib.rs b/sdk-tests/single-ata-test/src/lib.rs index 1bc808326f..7a1678604c 100644 --- a/sdk-tests/single-ata-test/src/lib.rs +++ b/sdk-tests/single-ata-test/src/lib.rs @@ -39,7 +39,7 @@ pub struct CreateAta<'info> { /// ATA account - macro should generate creation code. #[account(mut)] - #[light_account(init, associated_token, owner = ata_owner, mint = ata_mint, bump = params.ata_bump)] + #[light_account(init, associated_token::authority = ata_owner, associated_token::mint = ata_mint, associated_token::bump = params.ata_bump)] pub ata: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] diff --git a/sdk-tests/single-mint-test/src/lib.rs b/sdk-tests/single-mint-test/src/lib.rs index f40581e046..334688be63 100644 --- a/sdk-tests/single-mint-test/src/lib.rs +++ b/sdk-tests/single-mint-test/src/lib.rs @@ -42,12 +42,12 @@ pub struct CreateMint<'info> { /// CHECK: Initialized by light_mint CPI #[account(mut)] - #[light_account(init, mint, - mint_signer = mint_signer, - authority = fee_payer, - decimals = 9, - mint_seeds = &[MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], - mint_bump = params.mint_signer_bump + #[light_account(init, + mint::signer = mint_signer, + mint::authority = fee_payer, + mint::decimals = 9, + mint::seeds = &[MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_bump )] pub mint: UncheckedAccount<'info>, diff --git a/sdk-tests/single-token-test/src/lib.rs b/sdk-tests/single-token-test/src/lib.rs index c937c22225..2de77654ac 100644 --- a/sdk-tests/single-token-test/src/lib.rs +++ b/sdk-tests/single-token-test/src/lib.rs @@ -52,7 +52,7 @@ pub struct CreateTokenVault<'info> { seeds = [VAULT_SEED, mint.key().as_ref()], bump, )] - #[light_account(init, token, authority = [VAULT_SEED, self.mint.key()], mint = mint, owner = vault_authority, bump = params.vault_bump)] + #[light_account(init, token::authority = [VAULT_SEED, self.mint.key()], token::mint = mint, token::owner = vault_authority, token::bump = params.vault_bump)] pub vault: UncheckedAccount<'info>, #[account(address = COMPRESSIBLE_CONFIG_V1)] From 7b3fb7231af1ac03b98a496af25f39b10911ecd8 Mon Sep 17 00:00:00 2001 From: ananas Date: Thu, 22 Jan 2026 15:36:29 +0000 Subject: [PATCH 3/3] fix feedback --- sdk-libs/macros/docs/accounts/light_mint.md | 35 ++--- .../src/light_pdas/account/seed_extraction.rs | 11 +- .../src/light_pdas/accounts/light_account.rs | 135 ++++++++++++++++++ 3 files changed, 161 insertions(+), 20 deletions(-) diff --git a/sdk-libs/macros/docs/accounts/light_mint.md b/sdk-libs/macros/docs/accounts/light_mint.md index 851f6a4af6..72d1aef8fe 100644 --- a/sdk-libs/macros/docs/accounts/light_mint.md +++ b/sdk-libs/macros/docs/accounts/light_mint.md @@ -15,7 +15,7 @@ All parameters use the Anchor-style `mint::` namespace prefix. The account type mint::signer = mint_signer, mint::authority = authority, mint::decimals = 9, - mint::seeds = &[b"mint_signer", &[ctx.bumps.mint_signer]] + mint::seeds = &[b"mint_signer"] )] pub mint: UncheckedAccount<'info>, ``` @@ -43,7 +43,7 @@ pub struct CreateMint<'info> { mint::signer = mint_signer, mint::authority = authority, mint::decimals = 9, - mint::seeds = &[b"mint_signer", &[ctx.bumps.mint_signer]] + mint::seeds = &[b"mint_signer"] )] pub mint: UncheckedAccount<'info>, @@ -62,18 +62,18 @@ pub struct CreateMint<'info> { | `mint::signer` | Field reference | The AccountInfo that seeds the mint PDA. The mint address is derived from this signer. | | `mint::authority` | Field reference | The mint authority. Either a transaction signer or a PDA (if `mint::authority_seeds` is provided). | | `mint::decimals` | Expression | Token decimals (e.g., `9` for 9 decimal places). | -| `mint::seeds` | Slice expression | PDA signer seeds for `mint_signer`. Must be a `&[&[u8]]` expression that matches the `#[account(seeds = ...)]` on `mint_signer`, **including the bump**. | +| `mint::seeds` | Slice expression | Base PDA signer seeds for `mint_signer`. Must be a `&[&[u8]]` expression matching the base seeds in `#[account(seeds = ...)]` on `mint_signer`. The bump is appended automatically (see `mint::bump`). | ## Optional Attributes | Attribute | Type | Default | Description | |-----------|------|---------|-------------| -| `mint::bump` | Expression | Auto-derived | Explicit bump seed for the mint signer PDA. If not provided, uses `find_program_address`. | +| `mint::bump` | Expression | Auto-derived | Bump seed for the mint signer PDA, automatically appended to `mint::seeds`. If not provided, uses `ctx.bumps.`. | | `mint::freeze_authority` | Field reference | None | Optional freeze authority field. | | `mint::authority_seeds` | Slice expression | None | PDA signer seeds for `authority`. If not provided, `authority` must be a transaction signer. | | `mint::authority_bump` | Expression | Auto-derived | Explicit bump seed for authority PDA. | -| `mint::rent_payment` | Expression | `2u8` | Rent payment epochs for decompression. | -| `mint::write_top_up` | Expression | `0u32` | Write top-up lamports for decompression. | +| `mint::rent_payment` | Expression | `16u8` | Rent payment epochs for decompression. | +| `mint::write_top_up` | Expression | `766u32` | Write top-up lamports for decompression. | ## TokenMetadata Fields @@ -99,7 +99,8 @@ Optional fields for creating a mint with the TokenMetadata extension: mint::signer = mint_signer, mint::authority = fee_payer, mint::decimals = 9, - mint::seeds = &[SEED, self.authority.key().as_ref(), &[params.bump]], + mint::seeds = &[SEED, self.authority.key().as_ref()], + mint::bump = params.bump, // TokenMetadata fields mint::name = params.name.clone(), mint::symbol = params.symbol.clone(), @@ -138,14 +139,14 @@ let (mint_pda, bump) = light_token::instruction::find_mint_address(mint_signer.k ### Signer Seeds (mint::seeds) -The `mint::seeds` attribute provides the PDA signer seeds used for `invoke_signed` when calling the light token program. These seeds must derive to the `mint_signer` pubkey for the CPI to succeed. +The `mint::seeds` attribute provides the **base** PDA signer seeds used for `invoke_signed` when calling the light token program. The bump is automatically appended to these seeds (from `mint::bump` or `ctx.bumps.`). The complete seeds must derive to the `mint_signer` pubkey for the CPI to succeed. ```rust #[light_account(init, mint::signer = mint_signer, mint::authority = mint_authority, mint::decimals = 9, - mint::seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref(), &[params.mint_signer_bump]], + mint::seeds = &[LP_MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], mint::bump = params.mint_signer_bump )] pub mint: UncheckedAccount<'info>, @@ -156,10 +157,10 @@ pub mint: UncheckedAccount<'info>, - Use `.to_account_info().key` to get account pubkeys - The bump can be provided explicitly via `mint::bump` or auto-derived -The generated code uses these seeds to sign the CPI: +The generated code appends the bump and uses these seeds to sign the CPI: ```rust -let mint_seeds: &[&[u8]] = &[...]; // from mint::seeds attribute +let mint_seeds: &[&[u8]] = &[...base_seeds..., &[bump]]; // base from mint::seeds, bump appended invoke_signed(&mint_action_ix, &account_infos, &[mint_seeds])?; ``` @@ -198,7 +199,7 @@ pub struct CreateBasicMint<'info> { mint::signer = mint_signer, mint::authority = authority, mint::decimals = 6, - mint::seeds = &[b"mint", &[ctx.bumps.mint_signer]] + mint::seeds = &[b"mint"] )] pub mint: UncheckedAccount<'info>, @@ -229,8 +230,8 @@ pub struct CreateMintWithPdaAuthority<'info> { mint::signer = mint_signer, mint::authority = authority, mint::decimals = 9, - mint::seeds = &[b"mint", &[ctx.bumps.mint_signer]], - mint::authority_seeds = &[b"authority", &[ctx.bumps.authority]], + mint::seeds = &[b"mint"], + mint::authority_seeds = &[b"authority"], mint::authority_bump = params.authority_bump )] pub mint: UncheckedAccount<'info>, @@ -246,7 +247,7 @@ pub struct CreateMintWithPdaAuthority<'info> { mint::signer = mint_signer, mint::authority = authority, mint::decimals = 9, - mint::seeds = &[b"mint", &[bump]], + mint::seeds = &[b"mint"], mint::freeze_authority = freeze_auth )] pub mint: UncheckedAccount<'info>, @@ -262,7 +263,7 @@ pub freeze_auth: Signer<'info>, mint::signer = mint_signer, mint::authority = authority, mint::decimals = 9, - mint::seeds = &[b"mint", &[bump]], + mint::seeds = &[b"mint"], mint::rent_payment = 4, // 4 epochs of rent mint::write_top_up = 1000 // Extra lamports for writes )] @@ -288,7 +289,7 @@ pub struct CreateMintAndPda<'info> { mint::signer = mint_signer, mint::authority = authority, mint::decimals = 9, - mint::seeds = &[b"mint", &[ctx.bumps.mint_signer]] + mint::seeds = &[b"mint"] )] pub mint: UncheckedAccount<'info>, 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 07a2df303e..c3052bb4f2 100644 --- a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs +++ b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs @@ -480,9 +480,14 @@ fn parse_light_token_list( )?; let mut seeds = Vec::new(); for elem in &elems { - if let Ok(seed) = classify_seed_expr(elem, &instruction_args) { - seeds.push(seed); - } + let seed = classify_seed_expr(elem, &instruction_args) + .map_err(|e| { + syn::Error::new_spanned( + elem, + format!("invalid authority seed: {}", e), + ) + })?; + seeds.push(seed); } authority_seeds = Some(seeds); } else { 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 af2baf95ae..da291c6fb9 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs @@ -513,6 +513,24 @@ pub(super) fn parse_light_account_attr( } } } + // For mark-only associated_token, both authority and mint are required + // (needed to derive the ATA PDA at runtime) + if args.account_type == LightAccountType::AssociatedToken { + let has_authority = args.key_values.iter().any(|kv| kv.key == "authority"); + let has_mint = args.key_values.iter().any(|kv| kv.key == "mint"); + if !has_authority { + return Err(Error::new_spanned( + attr, + "#[light_account(associated_token, ...)] requires `associated_token::authority` parameter", + )); + } + if !has_mint { + return Err(Error::new_spanned( + attr, + "#[light_account(associated_token, ...)] requires `associated_token::mint` parameter", + )); + } + } return Ok(None); } @@ -1873,4 +1891,121 @@ mod tests { err ); } + + // ======================================================================== + // Mark-Only Associated Token Validation Tests + // ======================================================================== + + #[test] + fn test_parse_associated_token_mark_only_missing_authority_fails() { + // Mark-only associated_token requires authority + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("authority"), + "Expected error about missing authority, got: {}", + err + ); + } + + #[test] + fn test_parse_associated_token_mark_only_missing_mint_fails() { + // Mark-only associated_token requires mint + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("mint"), + "Expected error about missing mint, got: {}", + err + ); + } + + #[test] + fn test_parse_associated_token_mark_only_with_both_params_succeeds() { + // Mark-only associated_token with both authority and mint should succeed (returns None) + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); // Mark-only returns None + } + + // ======================================================================== + // Mixed Namespace Prefix Tests + // ======================================================================== + + #[test] + fn test_parse_mixed_token_and_associated_token_prefix_fails() { + // Mixing token:: with associated_token type should fail + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner, token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); + } + + #[test] + fn test_parse_mixed_associated_token_and_token_prefix_fails() { + // Mixing associated_token:: with token type should fail + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [b"auth"], associated_token::mint = mint)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); + } + + #[test] + fn test_parse_init_mixed_token_and_mint_prefix_fails() { + // Mixing token:: with mint:: in init mode should fail + let field: syn::Field = parse_quote! { + #[light_account(init, token, token::authority = [b"auth"], mint::decimals = 9)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); + } }