From fff845a2db6d4ed7da02da26e4c0d8d26380c386 Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:55:15 -0700 Subject: [PATCH 1/8] Fix constant length --- build/src/inject.rs | 23 ++++++++++++++++++++--- interface/src/market.rs | 4 ++-- program/src/dropset/market/register.s | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/build/src/inject.rs b/build/src/inject.rs index da782e1..e913dc7 100644 --- a/build/src/inject.rs +++ b/build/src/inject.rs @@ -65,11 +65,20 @@ impl Constant { } }; + let comment_line = format!("# {}", comment); + assert!( + comment_line.len() <= max_width, + "comment exceeds {} chars ({} chars): {}", + max_width, + comment_line.len(), + comment_line, + ); + let inline = format!(".equ {}, {} # {}", name, value_str, comment); if inline.len() <= max_width { inline } else { - format!("# {}\n.equ {}, {}", comment, name, value_str) + format!("{}\n.equ {}, {}", comment_line, name, value_str) } } } @@ -148,9 +157,17 @@ fn render_group(group: &ConstantGroup) -> String { if group.comment.is_empty() { directives.join("\n") } else { + let comment_line = format!("# {}", group.comment); + assert!( + comment_line.len() <= MAX_LINE_WIDTH, + "group comment exceeds {} chars ({} chars): {}", + MAX_LINE_WIDTH, + comment_line.len(), + comment_line, + ); format!( - "# {}\n{}\n{}\n{}", - group.comment, + "{}\n{}\n{}\n{}", + comment_line, SEPARATOR, directives.join("\n"), SEPARATOR, diff --git a/interface/src/market.rs b/interface/src/market.rs index ecd1ba9..1ffaf36 100644 --- a/interface/src/market.rs +++ b/interface/src/market.rs @@ -159,7 +159,7 @@ pub struct RegisterMarketFrame { pub input_shifted: u64, /// From Rent sysvar. pub lamports_per_byte: u64, - /// Return value from GetAccountDataSize CPI, to check token account data size at runtime. + /// Return value from spl_token_2022::GetAccountDataSize. pub token_account_data_size: u64, /// Pointer to mint account for vault initialization. pub mint: *const RuntimeAccount, @@ -211,7 +211,7 @@ constant_group! { INPUT_SHIFTED = offset!(input_shifted), /// From Rent sysvar. LAMPORTS_PER_BYTE = offset!(lamports_per_byte), - /// Return value from GetAccountDataSize CPI, to check token account data size at runtime. + /// Return value from spl_token_2022::GetAccountDataSize. TOKEN_ACCOUNT_DATA_SIZE = offset!(token_account_data_size), /// Pointer to mint account for vault initialization. MINT = offset!(mint), diff --git a/program/src/dropset/market/register.s b/program/src/dropset/market/register.s index 9d11197..fcc50ed 100644 --- a/program/src/dropset/market/register.s +++ b/program/src/dropset/market/register.s @@ -29,7 +29,7 @@ .equ RM_FM_INPUT_OFF, -576 # Saved input buffer pointer. .equ RM_FM_INPUT_SHIFTED_OFF, -568 # Saved input_shifted pointer. .equ RM_FM_LAMPORTS_PER_BYTE_OFF, -560 # From Rent sysvar. -# Return value from GetAccountDataSize CPI, to check token account data size at runtime. +# Return value from spl_token_2022::GetAccountDataSize. .equ RM_FM_TOKEN_ACCOUNT_DATA_SIZE_OFF, -552 # Pointer to mint account for vault initialization. .equ RM_FM_MINT_OFF, -544 From ca8225b333aa12a3265b998a6abe2db1f6c4cddb Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:19:11 -0700 Subject: [PATCH 2/8] Make frame declarative --- interface/src/market.rs | 162 +++++----- macros/src/constant_group/mod.rs | 2 +- macros/src/frame.rs | 494 ++++++++++++++++++++++++++++++- macros/src/lib.rs | 28 +- 4 files changed, 582 insertions(+), 104 deletions(-) diff --git a/interface/src/market.rs b/interface/src/market.rs index 1ffaf36..43e4ae2 100644 --- a/interface/src/market.rs +++ b/interface/src/market.rs @@ -107,6 +107,7 @@ pub enum RegisterMarketAccounts { } // endregion: register_market_accounts +// region: register_market_stack #[svm_data] /// CPI instruction data for CreateAccount. pub struct CreateAccountData { @@ -131,7 +132,6 @@ cpi_accounts! { } } -// region: register_market_stack // region: signer_seeds_example signer_seeds! { PDASignerSeeds { @@ -146,126 +146,116 @@ signer_seeds! { // endregion: signer_seeds_example // region: frame_example -#[frame] +#[frame("frame")] +#[prefix("RM")] +#[inject("market/register")] +#[relative_offset( + PDA_SEEDS_TO_SOL_INSN, + pda_seeds, + sol_instruction, + "From pda_seeds to sol_instruction." +)] +#[relative_offset(PDA_TO_SIGNERS_SEEDS, pda, signers_seeds, "From pda to signers_seeds.")] +#[relative_offset( + CREATE_ACCT_DATA_TO_CPI_ACCT_METAS, + create_account_data, cpi_accounts.idx_0_meta, + "From create_account_data to CPI account metas.") +] /// Stack frame for REGISTER-MARKET. pub struct RegisterMarketFrame { /// Pointer to token program address. + #[offset(TOKEN_PROGRAM_ID)] pub token_program_id: *const Address, /// Pointer to program ID in input buffer. + #[offset(PROGRAM_ID)] pub program_id: *const Address, /// Saved input buffer pointer. + #[offset(INPUT)] pub input: u64, /// Saved input_shifted pointer. + #[offset(INPUT_SHIFTED)] pub input_shifted: u64, /// From Rent sysvar. + #[offset(LAMPORTS_PER_BYTE)] pub lamports_per_byte: u64, /// Return value from spl_token_2022::GetAccountDataSize. + #[offset(TOKEN_ACCOUNT_DATA_SIZE)] pub token_account_data_size: u64, /// Pointer to mint account for vault initialization. + #[offset(MINT)] pub mint: *const RuntimeAccount, /// Pointer to Rent sysvar account. + #[offset(RENT)] pub rent: *const RuntimeAccount, - /// Signer seeds for PDA derivation and CPI signing. + /// PDA signer seeds. + #[signer_seeds(PDA_SEEDS)] pub pda_seeds: PDASignerSeeds, - /// From `sol_try_find_program_address`. + /// PDA address. + #[pubkey_offsets(PDA)] pub pda: Address, - /// System Program pubkey, zero-initialized on stack + /// System Program pubkey. + #[pubkey_offsets(SYSTEM_PROGRAM_PUBKEY)] pub system_program_pubkey: Address, - /// Pointer to System Program ID in input buffer. + /// System Program ID in input buffer. + #[offset(SYSTEM_PROGRAM_ID)] pub system_program_id: *const Address, - /// Get return data program ID for CPI calls, zero-initialized on stack. + /// Get return data program ID for CPI calls. + #[offset(GET_RETURN_DATA_PROGRAM_ID)] pub get_return_data_program_id: Address, - /// CPI instruction data for CreateAccount. + /// CreateAccount instruction data. + #[offset(CREATE_ACCT_DATA)] + #[unaligned_offset( + CREATE_ACCT_LAMPORTS, + lamports, + "Lamports field within CreateAccount instruction data." + )] + #[unaligned_offset( + CREATE_ACCT_SPACE, + space, + "Space field within CreateAccount instruction data." + )] + #[unaligned_pubkey_offsets( + CREATE_ACCT_OWNER, + owner, + "Owner field within CreateAccount instruction data." + )] pub create_account_data: CreateAccountData, - /// CPI instruction data for InitializeAccount2. + /// InitializeAccount2 CPI instruction data. + #[offset(INIT_ACCT_2_DATA)] + #[unaligned_offset( + INIT_ACCT_2_DISC, + discriminant, + "Discriminant field within InitializeAccount2 instruction data." + )] + #[unaligned_pubkey_offsets( + INIT_ACCT_2_PROPRIETOR, + proprietor, + "Proprietor field within InitializeAccount2 instruction data." + )] pub initialize_account_2_data: InitializeAccount2, /// GetAccountDataSize CPI instruction data. + #[unaligned_offset(GET_ACCOUNT_DATA_SIZE_DATA)] pub get_account_data_size_data: u8, - /// CPI accounts for CreateAccount and InitializeAccount2. + /// CPI accounts. + #[cpi_accounts(CPI)] pub cpi_accounts: CPIAccounts, /// Signers seeds for CPI. + #[unaligned_offset(SIGNERS_SEEDS_ADDR, addr, "Signers seeds address.")] + #[unaligned_offset(SIGNERS_SEEDS_LEN, len, "Signers seeds length.")] pub signers_seeds: SolSignerSeeds, - /// Re-used across CPIs, zero-initialized on stack. + /// Solana instruction. + #[sol_instruction(SOL_INSN)] pub sol_instruction: SolInstruction, - /// From `sol_try_find_program_address`. + /// Bump seed. + #[offset(BUMP)] pub bump: u8, - /// Vault index for vault PDA derivation. + /// Vault index for PDA derivation. + #[unaligned_offset(VAULT_INDEX)] pub vault_index: u8, - /// Whether the current token program is Token 2022 (zero-initialized on stack). + /// Whether the current token program is Token 2022. + #[unaligned_offset(TOKEN_PROGRAM_IS_2022)] pub token_program_is_2022: u8, } // endregion: frame_example -constant_group! { - #[prefix("RM")] - #[inject("market/register")] - #[frame(RegisterMarketFrame)] - frame { - /// Pointer to token program address. - TOKEN_PROGRAM_ID = offset!(token_program_id), - /// Pointer to program ID in input buffer. - PROGRAM_ID = offset!(program_id), - /// Saved input buffer pointer. - INPUT = offset!(input), - /// Saved input_shifted pointer. - INPUT_SHIFTED = offset!(input_shifted), - /// From Rent sysvar. - LAMPORTS_PER_BYTE = offset!(lamports_per_byte), - /// Return value from spl_token_2022::GetAccountDataSize. - TOKEN_ACCOUNT_DATA_SIZE = offset!(token_account_data_size), - /// Pointer to mint account for vault initialization. - MINT = offset!(mint), - /// Pointer to Rent sysvar account. - RENT = offset!(rent), - /// PDA signer seeds. - PDA_SEEDS = signer_seeds!(pda_seeds), - /// PDA address. - PDA = pubkey_offsets!(pda), - /// System Program pubkey. - SYSTEM_PROGRAM_PUBKEY = pubkey_offsets!(system_program_pubkey), - /// System Program ID in input buffer. - SYSTEM_PROGRAM_ID = offset!(system_program_id), - /// Get return data program ID for CPI calls. - GET_RETURN_DATA_PROGRAM_ID = offset!(get_return_data_program_id), - /// CreateAccount instruction data. - CREATE_ACCT_DATA = offset!(create_account_data), - /// Lamports field within CreateAccount instruction data. - CREATE_ACCT_LAMPORTS = unaligned_offset!(create_account_data.lamports), - /// Space field within CreateAccount instruction data. - CREATE_ACCT_SPACE = unaligned_offset!(create_account_data.space), - /// Owner field within CreateAccount instruction data. - CREATE_ACCT_OWNER = unaligned_pubkey_offsets!(create_account_data.owner), - /// InitializeAccount2 CPI instruction data. - INIT_ACCT_2_DATA = offset!(initialize_account_2_data), - /// Discriminant field within InitializeAccount2 instruction data. - INIT_ACCT_2_DISC = unaligned_offset!(initialize_account_2_data.discriminant), - /// Proprietor field within InitializeAccount2 instruction data. - INIT_ACCT_2_PROPRIETOR = unaligned_pubkey_offsets!(initialize_account_2_data.proprietor), - /// GetAccountDataSize CPI instruction data. - GET_ACCOUNT_DATA_SIZE_DATA = unaligned_offset!(get_account_data_size_data), - /// CPI accounts. - CPI = cpi_accounts!(cpi_accounts), - /// Signers seeds address. - SIGNERS_SEEDS_ADDR = unaligned_offset!(signers_seeds.addr), - /// Signers seeds length. - SIGNERS_SEEDS_LEN = unaligned_offset!(signers_seeds.len), - /// Solana instruction. - SOL_INSN = sol_instruction!(sol_instruction), - /// Bump seed. - BUMP = offset!(bump), - /// Vault index for PDA derivation. - VAULT_INDEX = unaligned_offset!(vault_index), - /// Whether the current token program is Token 2022. - TOKEN_PROGRAM_IS_2022 = unaligned_offset!(token_program_is_2022), - /// From pda_seeds to sol_instruction. - PDA_SEEDS_TO_SOL_INSN = relative_offset!(pda_seeds, sol_instruction), - /// From pda to signers_seeds. - PDA_TO_SIGNERS_SEEDS = relative_offset!(pda, signers_seeds), - /// From create_account_data to CPI account metas. - CREATE_ACCT_DATA_TO_CPI_ACCT_METAS = relative_offset!( - create_account_data, cpi_accounts.idx_0_meta - ), - } -} - // endregion: register_market_stack diff --git a/macros/src/constant_group/mod.rs b/macros/src/constant_group/mod.rs index a213099..f259f28 100644 --- a/macros/src/constant_group/mod.rs +++ b/macros/src/constant_group/mod.rs @@ -1,5 +1,5 @@ mod expand; -mod parse; +pub(crate) mod parse; use syn::{Expr, Ident}; diff --git a/macros/src/frame.rs b/macros/src/frame.rs index 2b2b17b..877f347 100644 --- a/macros/src/frame.rs +++ b/macros/src/frame.rs @@ -1,10 +1,17 @@ +use proc_macro2::Span; use quote::quote; +use syn::Ident; -use crate::attrs::extract_doc_comment; +use crate::attrs::{ + extract_attr_string, extract_doc_comment, extract_inject_target, validate_comment, + validate_name, +}; +use crate::constant_group::parse::ConstantGroupInput; +use crate::constant_group::{self, ConstantDef, ConstantKind}; use crate::sbpf_config; use crate::shared_state; -/// Extract the last path segment from a type (e.g. `crate::Foo` → `Foo`). +/// Extract the last path segment from a type (e.g. `crate::Foo` -> `Foo`). fn type_name(ty: &syn::Type) -> String { match ty { syn::Type::Path(tp) => tp @@ -17,19 +24,410 @@ fn type_name(ty: &syn::Type) -> String { } } -/// Expand `#[frame]` on a struct into the struct with -/// `#[repr(C, align(8))]` applied (aligned to `BPF_ALIGN_OF_U128`) -/// and a compile-time assertion that it fits within one SBPf stack frame. -pub fn expand(input: &syn::ItemStruct) -> proc_macro2::TokenStream { - let attrs = &input.attrs; +// region: field attribute names +const OFFSET: &str = "offset"; +const UNALIGNED_OFFSET: &str = "unaligned_offset"; +const PUBKEY_OFFSETS: &str = "pubkey_offsets"; +const UNALIGNED_PUBKEY_OFFSETS: &str = "unaligned_pubkey_offsets"; +const SIGNER_SEEDS: &str = "signer_seeds"; +const CPI_ACCOUNTS: &str = "cpi_accounts"; +const SOL_INSTRUCTION: &str = "sol_instruction"; + +const FIELD_ATTR_NAMES: &[&str] = &[ + OFFSET, + UNALIGNED_OFFSET, + PUBKEY_OFFSETS, + UNALIGNED_PUBKEY_OFFSETS, + SIGNER_SEEDS, + CPI_ACCOUNTS, + SOL_INSTRUCTION, +]; +// endregion: field attribute names + +// region: struct attribute names +const RELATIVE_OFFSET: &str = "relative_offset"; + +const STRUCT_ATTR_NAMES: &[&str] = &["inject", "prefix", RELATIVE_OFFSET]; +// endregion: struct attribute names + +/// Returns true if `attr` is one of the custom field-level constant attributes. +fn is_field_const_attr(attr: &syn::Attribute) -> bool { + FIELD_ATTR_NAMES + .iter() + .any(|name| attr.path().is_ident(name)) +} + +/// Returns true if `attr` is a custom struct-level attribute consumed by `#[frame]`. +fn is_struct_const_attr(attr: &syn::Attribute) -> bool { + STRUCT_ATTR_NAMES + .iter() + .any(|name| attr.path().is_ident(name)) +} + +/// Result of parsing a field constant attribute. +enum FieldAttrForm { + /// `#[kind(NAME)]` or `#[kind(NAME, "doc")]`. + Primary { + name: Ident, + doc_override: Option, + }, + /// `#[kind(NAME, subfield.nested, "doc")]`. + SubField { + name: Ident, + sub_fields: Vec, + doc: String, + }, +} + +/// Parse a field constant attribute in a single pass. +/// +/// Detects the form by looking at the token after the first comma: +/// - No comma → `Primary { doc_override: None }` +/// - Comma then string literal → `Primary { doc_override: Some(doc) }` +/// - Comma then identifier → `SubField { sub_fields, doc }` +fn parse_field_const_attr(attr: &syn::Attribute) -> syn::Result { + attr.parse_args_with(|input: syn::parse::ParseStream| { + let name: Ident = input.parse()?; + + if input.is_empty() { + return Ok(FieldAttrForm::Primary { + name, + doc_override: None, + }); + } + + input.parse::()?; + + if input.peek(syn::LitStr) { + let lit: syn::LitStr = input.parse()?; + return Ok(FieldAttrForm::Primary { + name, + doc_override: Some(lit.value()), + }); + } + + // Sub-field form: parse field chain then doc string. + let mut sub_fields = Vec::new(); + let first: Ident = input.parse()?; + sub_fields.push(syn::Member::Named(first)); + while input.peek(syn::Token![.]) { + input.parse::()?; + let member: Ident = input.parse()?; + sub_fields.push(syn::Member::Named(member)); + } + input.parse::()?; + let doc_lit: syn::LitStr = input.parse()?; + + Ok(FieldAttrForm::SubField { + name, + sub_fields, + doc: doc_lit.value(), + }) + }) +} + +/// Parse a `#[relative_offset(NAME, from, to, "doc")]` struct-level attribute. +fn parse_relative_offset_attr(attr: &syn::Attribute) -> syn::Result { + attr.parse_args_with(|input: syn::parse::ParseStream| { + let name: Ident = input.parse()?; + if let Err(e) = validate_name(&name.to_string()) { + return Err(syn::Error::new(name.span(), e)); + } + input.parse::()?; + + // Parse from field chain. + let from_fields = parse_member_chain(input)?; + input.parse::()?; + + // Parse to field chain. + let to_fields = parse_member_chain(input)?; + input.parse::()?; + + // Parse doc string. + let doc_lit: syn::LitStr = input.parse()?; + let doc = doc_lit.value(); + if let Err(e) = validate_comment(&doc) { + return Err(syn::Error::new(doc_lit.span(), e)); + } + + Ok(ConstantDef { + doc, + name, + kind: ConstantKind::RelativeOffset { + ty: None, + from_fields, + to_fields, + }, + }) + }) +} + +/// Parse a dotted member chain: `field.sub.nested`. +fn parse_member_chain(input: syn::parse::ParseStream) -> syn::Result> { + let mut fields = Vec::new(); + let first: Ident = input.parse()?; + fields.push(syn::Member::Named(first)); + while input.peek(syn::Token![.]) { + input.parse::()?; + let member: Ident = input.parse()?; + fields.push(syn::Member::Named(member)); + } + Ok(fields) +} + +/// Build `ConstantDef`s from a single field's attributes. +fn field_constants( + field: &syn::Field, + field_doc: &Option, + frame_name: &str, +) -> syn::Result> { + let mut defs = Vec::new(); + let field_ident = field.ident.as_ref().expect("frame fields must be named"); + let span = field_ident.span(); + + for attr in &field.attrs { + if !is_field_const_attr(attr) { + continue; + } + + let kind_name = attr.path().get_ident().unwrap().to_string(); + + let parsed = parse_field_const_attr(attr)?; + + match kind_name.as_str() { + OFFSET => match parsed { + FieldAttrForm::Primary { name, doc_override } => { + let doc = resolve_doc(doc_override, field_doc, &name)?; + validate_constant_name(&name)?; + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::FrameOffset { + fields: vec![syn::Member::Named(field_ident.clone())], + }, + }); + } + FieldAttrForm::SubField { + name, + sub_fields, + doc, + } => { + validate_constant_name(&name)?; + validate_constant_doc(&doc, attr)?; + let mut fields = vec![syn::Member::Named(field_ident.clone())]; + fields.extend(sub_fields); + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::FrameOffset { fields }, + }); + } + }, + UNALIGNED_OFFSET => match parsed { + FieldAttrForm::Primary { name, doc_override } => { + let doc = resolve_doc(doc_override, field_doc, &name)?; + validate_constant_name(&name)?; + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::UnalignedFrameOffset { + fields: vec![syn::Member::Named(field_ident.clone())], + }, + }); + } + FieldAttrForm::SubField { + name, + sub_fields, + doc, + } => { + validate_constant_name(&name)?; + validate_constant_doc(&doc, attr)?; + let mut fields = vec![syn::Member::Named(field_ident.clone())]; + fields.extend(sub_fields); + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::UnalignedFrameOffset { fields }, + }); + } + }, + PUBKEY_OFFSETS => match parsed { + FieldAttrForm::Primary { name, doc_override } => { + let doc = resolve_doc(doc_override, field_doc, &name)?; + validate_constant_name(&name)?; + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::FramePubkeyOffsets { + fields: vec![syn::Member::Named(field_ident.clone())], + }, + }); + } + FieldAttrForm::SubField { + name, + sub_fields, + doc, + } => { + validate_constant_name(&name)?; + validate_constant_doc(&doc, attr)?; + let mut fields = vec![syn::Member::Named(field_ident.clone())]; + fields.extend(sub_fields); + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::FramePubkeyOffsets { fields }, + }); + } + }, + UNALIGNED_PUBKEY_OFFSETS => match parsed { + FieldAttrForm::Primary { .. } => { + return Err(syn::Error::new_spanned( + attr, + "unaligned_pubkey_offsets requires sub-field form: \ + #[unaligned_pubkey_offsets(NAME, subfield, \"doc\")]", + )); + } + FieldAttrForm::SubField { + name, + sub_fields, + doc, + } => { + validate_constant_name(&name)?; + validate_constant_doc(&doc, attr)?; + let mut fields = vec![syn::Member::Named(field_ident.clone())]; + fields.extend(sub_fields); + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::UnalignedFramePubkeyOffsets { fields }, + }); + } + }, + SIGNER_SEEDS => { + let (name, doc) = primary_only(parsed, attr, field_doc)?; + let field_names = + shared_state::lookup_signer_seed_fields(frame_name, &field_ident.to_string()) + .map_err(|e| syn::Error::new(span, e))?; + + let seeds: Vec = field_names.iter().map(|n| Ident::new(n, span)).collect(); + + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::SignerSeeds { + parent_field: field_ident.clone(), + seeds, + }, + }); + } + CPI_ACCOUNTS => { + let (name, doc) = primary_only(parsed, attr, field_doc)?; + let field_names = + shared_state::lookup_cpi_account_fields(frame_name, &field_ident.to_string()) + .map_err(|e| syn::Error::new(span, e))?; + + let accounts: Vec = + field_names.iter().map(|n| Ident::new(n, span)).collect(); + + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::CpiAccounts { + parent_field: field_ident.clone(), + accounts, + }, + }); + } + SOL_INSTRUCTION => { + let (name, doc) = primary_only(parsed, attr, field_doc)?; + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::SolInstruction { + fields: vec![syn::Member::Named(field_ident.clone())], + }, + }); + } + _ => {} + } + } + + Ok(defs) +} + +/// Extract name and doc from a primary-only attribute, erroring on sub-field form. +fn primary_only( + parsed: FieldAttrForm, + attr: &syn::Attribute, + field_doc: &Option, +) -> syn::Result<(Ident, String)> { + match parsed { + FieldAttrForm::Primary { name, doc_override } => { + let doc = resolve_doc(doc_override, field_doc, &name)?; + validate_constant_name(&name)?; + Ok((name, doc)) + } + FieldAttrForm::SubField { .. } => Err(syn::Error::new_spanned( + attr, + "this attribute only supports primary form: #[kind(NAME)]", + )), + } +} + +/// Resolve the doc for a primary constant: use override if provided, else field doc. +fn resolve_doc( + doc_override: Option, + field_doc: &Option, + name: &Ident, +) -> syn::Result { + let doc = doc_override.or_else(|| field_doc.clone()).ok_or_else(|| { + syn::Error::new( + name.span(), + "field must have a /// doc comment or the attribute must include a doc string", + ) + })?; + if let Err(e) = validate_comment(&doc) { + return Err(syn::Error::new(name.span(), e)); + } + Ok(doc) +} + +fn validate_constant_name(name: &Ident) -> syn::Result<()> { + validate_name(&name.to_string()).map_err(|e| syn::Error::new(name.span(), e)) +} + +fn validate_constant_doc(doc: &str, attr: &syn::Attribute) -> syn::Result<()> { + validate_comment(doc).map_err(|e| syn::Error::new_spanned(attr, e)) +} + +/// Strip custom attributes from fields, returning cleaned fields. +fn strip_field_attrs(fields: &syn::Fields) -> syn::Fields { + let mut fields = fields.clone(); + if let syn::Fields::Named(ref mut named) = fields { + for field in &mut named.named { + field.attrs.retain(|a| !is_field_const_attr(a)); + } + } + fields +} + +/// Expand `#[frame]` or `#[frame("mod_name")]` on a struct. +/// +/// When no `#[inject]` is present on the struct, this behaves as before: +/// applies `#[repr(C, align(8))]` and asserts the struct fits in one +/// SBPF stack frame. +/// +/// When `#[inject]` is present, it also generates a constant group module +/// from field-level and struct-level constant attributes, eliminating the +/// need for a separate `constant_group!` invocation. +pub fn expand(mod_name: Option, input: &syn::ItemStruct) -> proc_macro2::TokenStream { let vis = &input.vis; let ident = &input.ident; let generics = &input.generics; - let fields = &input.fields; let semi = &input.semi_token; let max = sbpf_config::stack_frame_size(); - // Register frame metadata for constant_group! lookup. + // Register frame metadata in shared state first (needed for lookups). let field_types: Vec<(String, String)> = input .fields .iter() @@ -40,16 +438,86 @@ pub fn expand(input: &syn::ItemStruct) -> proc_macro2::TokenStream { }) .collect(); let doc = extract_doc_comment(&input.attrs).unwrap_or_default(); - shared_state::register_frame(&ident.to_string(), field_types, doc); + shared_state::register_frame(&ident.to_string(), field_types, doc.clone()); - quote! { - #(#attrs)* + // Strip custom struct-level attributes from emitted output. + let struct_attrs: Vec<_> = input + .attrs + .iter() + .filter(|a| !is_struct_const_attr(a)) + .collect(); + + // Strip custom field-level attributes. + let stripped_fields = strip_field_attrs(&input.fields); + + let struct_def = quote! { + #(#struct_attrs)* #[repr(C, align(8))] - #vis struct #ident #generics #fields #semi + #vis struct #ident #generics #stripped_fields #semi const _: () = assert!( core::mem::size_of::<#ident>() <= #max, "frame struct must fit within one SBPf stack frame (4096 bytes)", ); + }; + + // Check whether constant group generation is requested. + let target = extract_inject_target(&input.attrs); + if target.is_none() { + return struct_def; + } + let target = target.unwrap(); + let prefix = extract_attr_string(&input.attrs, "prefix"); + let mod_name = mod_name.unwrap_or_else(|| { + panic!( + "#[frame] with #[inject] requires a module name argument, \ + e.g. #[frame(\"frame\")]" + ) + }); + let mod_ident = Ident::new(&mod_name, Span::call_site()); + let frame_name = ident.to_string(); + + // Validate the group doc comment if present. + if !doc.is_empty() + && let Err(e) = validate_comment(&doc) + { + return syn::Error::new_spanned(ident, e).to_compile_error(); + } + + // Collect constants from field attributes. + let mut constants = Vec::new(); + for field in &input.fields { + let field_doc = extract_doc_comment(&field.attrs); + match field_constants(field, &field_doc, &frame_name) { + Ok(defs) => constants.extend(defs), + Err(e) => return e.to_compile_error(), + } + } + + // Collect relative_offset constants from struct-level attributes. + for attr in &input.attrs { + if attr.path().is_ident(RELATIVE_OFFSET) { + match parse_relative_offset_attr(attr) { + Ok(def) => constants.push(def), + Err(e) => return e.to_compile_error(), + } + } + } + + // Build ConstantGroupInput and expand via the existing codegen. + let frame_type: syn::Path = syn::parse_quote!(#ident); + let group_input = ConstantGroupInput { + target, + prefix, + frame_type: Some(frame_type), + doc, + mod_name: mod_ident, + constants, + }; + let group_module = constant_group::expand(&group_input); + + quote! { + #struct_def + #group_module } } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 5bd9aa5..9c3bfa9 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -160,19 +160,39 @@ pub fn instruction_data(attr: TokenStream, item: TokenStream) -> TokenStream { /// Registers field-to-type mappings and the doc comment in shared state /// for automatic lookup by `constant_group!`. /// +/// When called with a module name argument and combined with `#[inject]` +/// and `#[prefix]` on the struct, also generates a constant group module +/// from field-level attributes (`#[offset]`, `#[unaligned_offset]`, +/// `#[pubkey_offsets]`, `#[signer_seeds]`, `#[cpi_accounts]`, +/// `#[sol_instruction]`) and struct-level `#[relative_offset]` attrs. +/// /// ```ignore -/// #[frame] +/// #[frame("frame")] +/// #[prefix("RM")] +/// #[inject("market/register")] /// /// Stack frame for REGISTER-MARKET. /// pub struct RegisterMarketFrame { +/// /// Pointer to token program address. +/// #[offset(TOKEN_PROGRAM_ID)] +/// pub token_program_id: *const Address, +/// /// PDA signer seeds. +/// #[signer_seeds(PDA_SEEDS)] /// pub pda_seeds: PdaSignerSeeds, -/// pub pda: Address, +/// /// Bump seed. +/// #[offset(BUMP)] /// pub bump: u8, /// } /// ``` #[proc_macro_attribute] -pub fn frame(_attr: TokenStream, item: TokenStream) -> TokenStream { +pub fn frame(attr: TokenStream, item: TokenStream) -> TokenStream { + let mod_name = if attr.is_empty() { + None + } else { + let lit = parse_macro_input!(attr as LitStr); + Some(lit.value()) + }; let input = parse_macro_input!(item as syn::ItemStruct); - TokenStream::from(frame::expand(&input)) + TokenStream::from(frame::expand(mod_name, &input)) } /// Attribute macro for instruction accounts enums. From e858ce38f85c8570794750300c9db20c645a7b22 Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:27:07 -0700 Subject: [PATCH 3/8] Make frame more declarative --- interface/src/market.rs | 55 ++++++++++++++++++++++++++++------------- macros/src/frame.rs | 52 +++++++++++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 29 deletions(-) diff --git a/interface/src/market.rs b/interface/src/market.rs index 43e4ae2..da098ef 100644 --- a/interface/src/market.rs +++ b/interface/src/market.rs @@ -164,44 +164,57 @@ signer_seeds! { /// Stack frame for REGISTER-MARKET. pub struct RegisterMarketFrame { /// Pointer to token program address. - #[offset(TOKEN_PROGRAM_ID)] + #[offset] pub token_program_id: *const Address, + /// Pointer to program ID in input buffer. - #[offset(PROGRAM_ID)] + #[offset] pub program_id: *const Address, + /// Saved input buffer pointer. - #[offset(INPUT)] + #[offset] pub input: u64, + /// Saved input_shifted pointer. - #[offset(INPUT_SHIFTED)] + #[offset] pub input_shifted: u64, + /// From Rent sysvar. - #[offset(LAMPORTS_PER_BYTE)] + #[offset] pub lamports_per_byte: u64, + /// Return value from spl_token_2022::GetAccountDataSize. - #[offset(TOKEN_ACCOUNT_DATA_SIZE)] + #[offset] pub token_account_data_size: u64, + /// Pointer to mint account for vault initialization. - #[offset(MINT)] + #[offset] pub mint: *const RuntimeAccount, + /// Pointer to Rent sysvar account. - #[offset(RENT)] + #[offset] pub rent: *const RuntimeAccount, + /// PDA signer seeds. - #[signer_seeds(PDA_SEEDS)] + #[signer_seeds] pub pda_seeds: PDASignerSeeds, + /// PDA address. - #[pubkey_offsets(PDA)] + #[pubkey_offsets] pub pda: Address, + /// System Program pubkey. - #[pubkey_offsets(SYSTEM_PROGRAM_PUBKEY)] + #[pubkey_offsets] pub system_program_pubkey: Address, + /// System Program ID in input buffer. - #[offset(SYSTEM_PROGRAM_ID)] + #[offset] pub system_program_id: *const Address, + /// Get return data program ID for CPI calls. - #[offset(GET_RETURN_DATA_PROGRAM_ID)] + #[offset] pub get_return_data_program_id: Address, + /// CreateAccount instruction data. #[offset(CREATE_ACCT_DATA)] #[unaligned_offset( @@ -220,6 +233,7 @@ pub struct RegisterMarketFrame { "Owner field within CreateAccount instruction data." )] pub create_account_data: CreateAccountData, + /// InitializeAccount2 CPI instruction data. #[offset(INIT_ACCT_2_DATA)] #[unaligned_offset( @@ -233,27 +247,34 @@ pub struct RegisterMarketFrame { "Proprietor field within InitializeAccount2 instruction data." )] pub initialize_account_2_data: InitializeAccount2, + /// GetAccountDataSize CPI instruction data. - #[unaligned_offset(GET_ACCOUNT_DATA_SIZE_DATA)] + #[unaligned_offset] pub get_account_data_size_data: u8, + /// CPI accounts. #[cpi_accounts(CPI)] pub cpi_accounts: CPIAccounts, + /// Signers seeds for CPI. #[unaligned_offset(SIGNERS_SEEDS_ADDR, addr, "Signers seeds address.")] #[unaligned_offset(SIGNERS_SEEDS_LEN, len, "Signers seeds length.")] pub signers_seeds: SolSignerSeeds, + /// Solana instruction. #[sol_instruction(SOL_INSN)] pub sol_instruction: SolInstruction, + /// Bump seed. - #[offset(BUMP)] + #[offset] pub bump: u8, + /// Vault index for PDA derivation. - #[unaligned_offset(VAULT_INDEX)] + #[unaligned_offset] pub vault_index: u8, + /// Whether the current token program is Token 2022. - #[unaligned_offset(TOKEN_PROGRAM_IS_2022)] + #[unaligned_offset] pub token_program_is_2022: u8, } // endregion: frame_example diff --git a/macros/src/frame.rs b/macros/src/frame.rs index 877f347..ac14651 100644 --- a/macros/src/frame.rs +++ b/macros/src/frame.rs @@ -1,3 +1,4 @@ +use heck::ToShoutySnakeCase; use proc_macro2::Span; use quote::quote; use syn::Ident; @@ -66,9 +67,10 @@ fn is_struct_const_attr(attr: &syn::Attribute) -> bool { /// Result of parsing a field constant attribute. enum FieldAttrForm { - /// `#[kind(NAME)]` or `#[kind(NAME, "doc")]`. + /// `#[kind]`, `#[kind(NAME)]`, or `#[kind(NAME, "doc")]`. Primary { - name: Ident, + /// `None` when the name should be inferred from the field name. + name: Option, doc_override: Option, }, /// `#[kind(NAME, subfield.nested, "doc")]`. @@ -81,17 +83,27 @@ enum FieldAttrForm { /// Parse a field constant attribute in a single pass. /// -/// Detects the form by looking at the token after the first comma: -/// - No comma → `Primary { doc_override: None }` -/// - Comma then string literal → `Primary { doc_override: Some(doc) }` -/// - Comma then identifier → `SubField { sub_fields, doc }` +/// Forms: +/// - Empty args (`#[kind]`) → `Primary { name: None }` +/// - `#[kind(NAME)]` → `Primary { name: Some(NAME) }` +/// - `#[kind(NAME, "doc")]` → `Primary { name: Some(NAME), doc_override }` +/// - `#[kind(NAME, subfield, "doc")]` → `SubField` fn parse_field_const_attr(attr: &syn::Attribute) -> syn::Result { + // Handle bare `#[kind]` with no parenthesized args. + let has_args = matches!(&attr.meta, syn::Meta::List(_)); + if !has_args { + return Ok(FieldAttrForm::Primary { + name: None, + doc_override: None, + }); + } + attr.parse_args_with(|input: syn::parse::ParseStream| { let name: Ident = input.parse()?; if input.is_empty() { return Ok(FieldAttrForm::Primary { - name, + name: Some(name), doc_override: None, }); } @@ -101,7 +113,7 @@ fn parse_field_const_attr(attr: &syn::Attribute) -> syn::Result { if input.peek(syn::LitStr) { let lit: syn::LitStr = input.parse()?; return Ok(FieldAttrForm::Primary { - name, + name: Some(name), doc_override: Some(lit.value()), }); } @@ -175,6 +187,17 @@ fn parse_member_chain(input: syn::parse::ParseStream) -> syn::Result, field_ident: &Ident) -> Ident { + explicit.unwrap_or_else(|| { + Ident::new( + &field_ident.to_string().to_shouty_snake_case(), + field_ident.span(), + ) + }) +} + /// Build `ConstantDef`s from a single field's attributes. fn field_constants( field: &syn::Field, @@ -197,6 +220,7 @@ fn field_constants( match kind_name.as_str() { OFFSET => match parsed { FieldAttrForm::Primary { name, doc_override } => { + let name = resolve_name(name, field_ident); let doc = resolve_doc(doc_override, field_doc, &name)?; validate_constant_name(&name)?; defs.push(ConstantDef { @@ -225,6 +249,7 @@ fn field_constants( }, UNALIGNED_OFFSET => match parsed { FieldAttrForm::Primary { name, doc_override } => { + let name = resolve_name(name, field_ident); let doc = resolve_doc(doc_override, field_doc, &name)?; validate_constant_name(&name)?; defs.push(ConstantDef { @@ -253,6 +278,7 @@ fn field_constants( }, PUBKEY_OFFSETS => match parsed { FieldAttrForm::Primary { name, doc_override } => { + let name = resolve_name(name, field_ident); let doc = resolve_doc(doc_override, field_doc, &name)?; validate_constant_name(&name)?; defs.push(ConstantDef { @@ -304,7 +330,7 @@ fn field_constants( } }, SIGNER_SEEDS => { - let (name, doc) = primary_only(parsed, attr, field_doc)?; + let (name, doc) = primary_only(parsed, attr, field_ident, field_doc)?; let field_names = shared_state::lookup_signer_seed_fields(frame_name, &field_ident.to_string()) .map_err(|e| syn::Error::new(span, e))?; @@ -321,7 +347,7 @@ fn field_constants( }); } CPI_ACCOUNTS => { - let (name, doc) = primary_only(parsed, attr, field_doc)?; + let (name, doc) = primary_only(parsed, attr, field_ident, field_doc)?; let field_names = shared_state::lookup_cpi_account_fields(frame_name, &field_ident.to_string()) .map_err(|e| syn::Error::new(span, e))?; @@ -339,7 +365,7 @@ fn field_constants( }); } SOL_INSTRUCTION => { - let (name, doc) = primary_only(parsed, attr, field_doc)?; + let (name, doc) = primary_only(parsed, attr, field_ident, field_doc)?; defs.push(ConstantDef { doc, name, @@ -359,17 +385,19 @@ fn field_constants( fn primary_only( parsed: FieldAttrForm, attr: &syn::Attribute, + field_ident: &Ident, field_doc: &Option, ) -> syn::Result<(Ident, String)> { match parsed { FieldAttrForm::Primary { name, doc_override } => { + let name = resolve_name(name, field_ident); let doc = resolve_doc(doc_override, field_doc, &name)?; validate_constant_name(&name)?; Ok((name, doc)) } FieldAttrForm::SubField { .. } => Err(syn::Error::new_spanned( attr, - "this attribute only supports primary form: #[kind(NAME)]", + "this attribute only supports primary form: #[kind] or #[kind(NAME)]", )), } } From 13e67d2b6792f96e16a3923c847dc6828eea9c38 Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:30:44 -0700 Subject: [PATCH 4/8] Change local struct name --- interface/src/market.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/src/market.rs b/interface/src/market.rs index da098ef..37f67f6 100644 --- a/interface/src/market.rs +++ b/interface/src/market.rs @@ -134,7 +134,7 @@ cpi_accounts! { // region: signer_seeds_example signer_seeds! { - PDASignerSeeds { + SignerSeeds { /// Market PDA: base mint address. Vault: market PDA address. idx_0, /// Market PDA: quote mint address. Vault: vault index (0 = base, 1 = quote). @@ -197,7 +197,7 @@ pub struct RegisterMarketFrame { /// PDA signer seeds. #[signer_seeds] - pub pda_seeds: PDASignerSeeds, + pub pda_seeds: SignerSeeds, /// PDA address. #[pubkey_offsets] From 1c3363a1f97bdd1c942da09e7b70912ce16542a3 Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:34:44 -0700 Subject: [PATCH 5/8] Tweak stack layout --- interface/src/market.rs | 29 ++++++++++++++------------- program/src/dropset/common/memory.s | 2 +- program/src/dropset/market/register.s | 10 ++++----- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/interface/src/market.rs b/interface/src/market.rs index 37f67f6..b7c94f6 100644 --- a/interface/src/market.rs +++ b/interface/src/market.rs @@ -117,8 +117,6 @@ pub struct CreateAccountData { pub space: u64, /// Zero-initialized on stack. pub owner: Address, - /// Included for alignment on stack. - _pad: u32, } cpi_accounts! { @@ -234,6 +232,21 @@ pub struct RegisterMarketFrame { )] pub create_account_data: CreateAccountData, + /// GetAccountDataSize CPI instruction data. + #[unaligned_offset] + pub get_account_data_size_data: u8, + + /// Vault index for PDA derivation. + #[unaligned_offset] + pub vault_index: u8, + + /// Whether the current token program is Token 2022. + #[unaligned_offset] + pub token_program_is_2022: u8, + + /// Padding for 8-byte alignment after CreateAccountData. + _pad: u8, + /// InitializeAccount2 CPI instruction data. #[offset(INIT_ACCT_2_DATA)] #[unaligned_offset( @@ -248,10 +261,6 @@ pub struct RegisterMarketFrame { )] pub initialize_account_2_data: InitializeAccount2, - /// GetAccountDataSize CPI instruction data. - #[unaligned_offset] - pub get_account_data_size_data: u8, - /// CPI accounts. #[cpi_accounts(CPI)] pub cpi_accounts: CPIAccounts, @@ -268,14 +277,6 @@ pub struct RegisterMarketFrame { /// Bump seed. #[offset] pub bump: u8, - - /// Vault index for PDA derivation. - #[unaligned_offset] - pub vault_index: u8, - - /// Whether the current token program is Token 2022. - #[unaligned_offset] - pub token_program_is_2022: u8, } // endregion: frame_example diff --git a/program/src/dropset/common/memory.s b/program/src/dropset/common/memory.s index be811d9..cfca589 100644 --- a/program/src/dropset/common/memory.s +++ b/program/src/dropset/common/memory.s @@ -90,5 +90,5 @@ .equ SIZE_OF_ADDRESS, 32 # Size of Address in bytes. .equ SIZE_OF_EMPTY_ACCOUNT, 10336 # Size of EmptyAccount in bytes. .equ SIZE_OF_MARKET_HEADER, 43 # Size of MarketHeader in bytes. -.equ SIZE_OF_CREATE_ACCOUNT_DATA, 56 # Size of CreateAccountData in bytes. +.equ SIZE_OF_CREATE_ACCOUNT_DATA, 52 # Size of CreateAccountData in bytes. .equ SIZE_OF_INITIALIZE_ACCOUNT2, 33 # Size of InitializeAccount2 in bytes. diff --git a/program/src/dropset/market/register.s b/program/src/dropset/market/register.s index fcc50ed..0335aa3 100644 --- a/program/src/dropset/market/register.s +++ b/program/src/dropset/market/register.s @@ -74,6 +74,11 @@ .equ RM_FM_CREATE_ACCT_OWNER_CHUNK_2_UOFF, -340 # Owner field within CreateAccount instruction data (chunk 3). .equ RM_FM_CREATE_ACCT_OWNER_CHUNK_3_UOFF, -332 +# GetAccountDataSize CPI instruction data. +.equ RM_FM_GET_ACCOUNT_DATA_SIZE_DATA_UOFF, -324 +.equ RM_FM_VAULT_INDEX_UOFF, -323 # Vault index for PDA derivation. +# Whether the current token program is Token 2022. +.equ RM_FM_TOKEN_PROGRAM_IS_2022_UOFF, -322 # InitializeAccount2 CPI instruction data. .equ RM_FM_INIT_ACCT_2_DATA_OFF, -320 # Discriminant field within InitializeAccount2 instruction data. @@ -88,8 +93,6 @@ .equ RM_FM_INIT_ACCT_2_PROPRIETOR_CHUNK_2_UOFF, -303 # Proprietor field within InitializeAccount2 instruction data (chunk 3). .equ RM_FM_INIT_ACCT_2_PROPRIETOR_CHUNK_3_UOFF, -295 -# GetAccountDataSize CPI instruction data. -.equ RM_FM_GET_ACCOUNT_DATA_SIZE_DATA_UOFF, -287 .equ RM_FM_CPI_N_ACCOUNTS, 3 # Number of CPI accounts. .equ RM_FM_CPI_SOL_ACCT_INFO_OFF, -280 # Start of SolAccountInfo vector. .equ RM_FM_CPI_SOL_ACCT_META_OFF, -112 # Start of SolAccountMeta vector. @@ -165,9 +168,6 @@ .equ RM_FM_SOL_INSN_DATA_UOFF, -24 # SolInstruction data pointer. .equ RM_FM_SOL_INSN_DATA_LEN_UOFF, -16 # SolInstruction data length. .equ RM_FM_BUMP_OFF, -8 # Bump seed. -.equ RM_FM_VAULT_INDEX_UOFF, -7 # Vault index for PDA derivation. -# Whether the current token program is Token 2022. -.equ RM_FM_TOKEN_PROGRAM_IS_2022_UOFF, -6 # From pda_seeds to sol_instruction. .equ RM_FM_PDA_SEEDS_TO_SOL_INSN_REL_OFF_IMM, 480 # From pda to signers_seeds. From 5654a7c1688084bae5953334182bdf6d943aa51d Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:39:46 -0700 Subject: [PATCH 6/8] Review docs --- docs/src/development/build-scaffolding.md | 40 ++++++++++++++++++++--- macros/src/lib.rs | 23 ++++++++----- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/docs/src/development/build-scaffolding.md b/docs/src/development/build-scaffolding.md index aa92dd2..6dbad71 100644 --- a/docs/src/development/build-scaffolding.md +++ b/docs/src/development/build-scaffolding.md @@ -89,6 +89,13 @@ In frame-relative mode, generated constant names include a `_FM_` infix after the prefix (e.g. `RM_FM_PDA_OFF` instead of `RM_PDA_OFF`) to distinguish frame-relative constants from other offset constants. +::: tip +For frame structs, [`#[frame("mod")]`](#frame) with field attributes can +generate the constant group directly, making a separate `constant_group!` +invocation unnecessary. The `constant_group!` macro remains available for +non-frame groups (e.g. input buffer offsets, standalone immediates). +::: + Each group generates: @@ -152,6 +159,28 @@ mappings and the struct's doc comment in proc-macro shared state so that [`constant_group!`](#constant_group) can auto-discover frame fields and derive its header comment. +When called as `#[frame("module_name")]` with `#[inject("target")]` and +`#[prefix("PREFIX")]` on the struct, it also generates a constant group +module directly from field-level attributes, eliminating the need for a +separate `constant_group!` invocation. Supported field attributes: + +- `#[offset]`: aligned frame-relative offset (`_OFF` suffix). Name is + auto-inferred from the field name via `SCREAMING_SNAKE_CASE`, or + overridden with `#[offset(CUSTOM_NAME)]` +- `#[unaligned_offset]`: frame-relative offset without alignment (`_UOFF`) +- `#[pubkey_offsets]`: base offset + four chunk offsets +- `#[signer_seeds]`: auto-expands seed offsets from + [`signer_seeds!`](#signer_seeds) shared state +- `#[cpi_accounts]`: auto-expands CPI account offsets from + [`cpi_accounts!`](#cpi_accounts) shared state +- `#[sol_instruction]`: base offset + per-field `SolInstruction` offsets + +Sub-field access uses comma-separated form: +`#[unaligned_offset(NAME, subfield, "doc")]`. + +Struct-level `#[relative_offset(NAME, from, to, "doc")]` attributes compute +the difference between two field offsets. + ### `#[svm_data]` @@ -168,8 +197,9 @@ input buffer segments, tree nodes). Function-like macro that defines a `#[repr(C)]` struct where every field is typed as `SolSignerSeed`. Field names are registered in proc-macro shared state so that `signer_seeds!(field)` inside a -[`constant_group!`](#constant_group) can auto-discover all seed fields by -looking up the parent field's type on the frame struct. +[`constant_group!`](#constant_group), or an `#[signer_seeds]` field attribute +on a [`#[frame]`](#frame) struct, can auto-discover all seed fields by +looking up the parent field's type. @@ -178,9 +208,9 @@ looking up the parent field's type on the frame struct. Function-like macro that defines a `#[repr(C)]` struct with `SolAccountInfo` fields first (contiguous), then `SolAccountMeta` fields (contiguous), for each named account. Field names are registered in proc-macro shared state so that -`cpi_accounts!(field)` inside a [`constant_group!`](#constant_group) can -auto-discover all account fields by looking up the parent field's type on the -frame struct. +`cpi_accounts!(field)` inside a [`constant_group!`](#constant_group), or a +`#[cpi_accounts]` field attribute on a [`#[frame]`](#frame) struct, can +auto-discover all account fields by looking up the parent field's type. ### `size_of_group!` diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 9c3bfa9..aaacc7a 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -17,14 +17,21 @@ mod svm_data; /// Defines a group of assembly constants with an injection target. /// -/// Supports three constant kinds: -/// - `offset!(expr)` — signed offset, gets `_OFF` suffix. -/// - `immediate!(expr)` — signed immediate (i32), no suffix. -/// - `signer_seeds!(field)` — auto-expands seed offsets (requires `#[frame]`). -/// -/// With `#[frame(Type)]`, `offset!(field)` computes a negative frame-pointer- -/// relative offset with alignment enforcement, and the group's doc comment -/// defaults to the frame struct's doc. +/// Constant kinds: +/// - `offset!(expr)`: signed offset (`_OFF` suffix) +/// - `immediate!(expr)`: signed immediate (i32) +/// - `pubkey!(expr)`: 32-byte key split into chunk immediates +/// - `pubkey_offsets!(expr)`: base offset + four chunk offsets +/// +/// With `#[frame(Type)]`, additional frame-relative kinds: +/// - `offset!(field)`: negative frame-pointer-relative (`_OFF`) +/// - `unaligned_offset!(field)`: frame-relative without alignment (`_UOFF`) +/// - `pubkey_offsets!(field)`: frame-relative + chunk offsets +/// - `unaligned_pubkey_offsets!(field)`: same without alignment +/// - `signer_seeds!(field)`: auto-expands seed offsets +/// - `cpi_accounts!(field)`: auto-expands CPI account offsets +/// - `sol_instruction!(field)`: base offset + per-field offsets +/// - `relative_offset!(from, to)`: difference between two fields /// /// ```ignore /// constant_group! { From f2bd031142142fc3fa4f79bef5afb2d8fc62283d Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:44:09 -0700 Subject: [PATCH 7/8] Abstract frame macro logic --- macros/src/frame.rs | 551 ---------------------------- macros/src/frame/field_constants.rs | 210 +++++++++++ macros/src/frame/mod.rs | 163 ++++++++ macros/src/frame/parse.rs | 115 ++++++ 4 files changed, 488 insertions(+), 551 deletions(-) delete mode 100644 macros/src/frame.rs create mode 100644 macros/src/frame/field_constants.rs create mode 100644 macros/src/frame/mod.rs create mode 100644 macros/src/frame/parse.rs diff --git a/macros/src/frame.rs b/macros/src/frame.rs deleted file mode 100644 index ac14651..0000000 --- a/macros/src/frame.rs +++ /dev/null @@ -1,551 +0,0 @@ -use heck::ToShoutySnakeCase; -use proc_macro2::Span; -use quote::quote; -use syn::Ident; - -use crate::attrs::{ - extract_attr_string, extract_doc_comment, extract_inject_target, validate_comment, - validate_name, -}; -use crate::constant_group::parse::ConstantGroupInput; -use crate::constant_group::{self, ConstantDef, ConstantKind}; -use crate::sbpf_config; -use crate::shared_state; - -/// Extract the last path segment from a type (e.g. `crate::Foo` -> `Foo`). -fn type_name(ty: &syn::Type) -> String { - match ty { - syn::Type::Path(tp) => tp - .path - .segments - .last() - .map(|s| s.ident.to_string()) - .unwrap_or_default(), - _ => String::new(), - } -} - -// region: field attribute names -const OFFSET: &str = "offset"; -const UNALIGNED_OFFSET: &str = "unaligned_offset"; -const PUBKEY_OFFSETS: &str = "pubkey_offsets"; -const UNALIGNED_PUBKEY_OFFSETS: &str = "unaligned_pubkey_offsets"; -const SIGNER_SEEDS: &str = "signer_seeds"; -const CPI_ACCOUNTS: &str = "cpi_accounts"; -const SOL_INSTRUCTION: &str = "sol_instruction"; - -const FIELD_ATTR_NAMES: &[&str] = &[ - OFFSET, - UNALIGNED_OFFSET, - PUBKEY_OFFSETS, - UNALIGNED_PUBKEY_OFFSETS, - SIGNER_SEEDS, - CPI_ACCOUNTS, - SOL_INSTRUCTION, -]; -// endregion: field attribute names - -// region: struct attribute names -const RELATIVE_OFFSET: &str = "relative_offset"; - -const STRUCT_ATTR_NAMES: &[&str] = &["inject", "prefix", RELATIVE_OFFSET]; -// endregion: struct attribute names - -/// Returns true if `attr` is one of the custom field-level constant attributes. -fn is_field_const_attr(attr: &syn::Attribute) -> bool { - FIELD_ATTR_NAMES - .iter() - .any(|name| attr.path().is_ident(name)) -} - -/// Returns true if `attr` is a custom struct-level attribute consumed by `#[frame]`. -fn is_struct_const_attr(attr: &syn::Attribute) -> bool { - STRUCT_ATTR_NAMES - .iter() - .any(|name| attr.path().is_ident(name)) -} - -/// Result of parsing a field constant attribute. -enum FieldAttrForm { - /// `#[kind]`, `#[kind(NAME)]`, or `#[kind(NAME, "doc")]`. - Primary { - /// `None` when the name should be inferred from the field name. - name: Option, - doc_override: Option, - }, - /// `#[kind(NAME, subfield.nested, "doc")]`. - SubField { - name: Ident, - sub_fields: Vec, - doc: String, - }, -} - -/// Parse a field constant attribute in a single pass. -/// -/// Forms: -/// - Empty args (`#[kind]`) → `Primary { name: None }` -/// - `#[kind(NAME)]` → `Primary { name: Some(NAME) }` -/// - `#[kind(NAME, "doc")]` → `Primary { name: Some(NAME), doc_override }` -/// - `#[kind(NAME, subfield, "doc")]` → `SubField` -fn parse_field_const_attr(attr: &syn::Attribute) -> syn::Result { - // Handle bare `#[kind]` with no parenthesized args. - let has_args = matches!(&attr.meta, syn::Meta::List(_)); - if !has_args { - return Ok(FieldAttrForm::Primary { - name: None, - doc_override: None, - }); - } - - attr.parse_args_with(|input: syn::parse::ParseStream| { - let name: Ident = input.parse()?; - - if input.is_empty() { - return Ok(FieldAttrForm::Primary { - name: Some(name), - doc_override: None, - }); - } - - input.parse::()?; - - if input.peek(syn::LitStr) { - let lit: syn::LitStr = input.parse()?; - return Ok(FieldAttrForm::Primary { - name: Some(name), - doc_override: Some(lit.value()), - }); - } - - // Sub-field form: parse field chain then doc string. - let mut sub_fields = Vec::new(); - let first: Ident = input.parse()?; - sub_fields.push(syn::Member::Named(first)); - while input.peek(syn::Token![.]) { - input.parse::()?; - let member: Ident = input.parse()?; - sub_fields.push(syn::Member::Named(member)); - } - input.parse::()?; - let doc_lit: syn::LitStr = input.parse()?; - - Ok(FieldAttrForm::SubField { - name, - sub_fields, - doc: doc_lit.value(), - }) - }) -} - -/// Parse a `#[relative_offset(NAME, from, to, "doc")]` struct-level attribute. -fn parse_relative_offset_attr(attr: &syn::Attribute) -> syn::Result { - attr.parse_args_with(|input: syn::parse::ParseStream| { - let name: Ident = input.parse()?; - if let Err(e) = validate_name(&name.to_string()) { - return Err(syn::Error::new(name.span(), e)); - } - input.parse::()?; - - // Parse from field chain. - let from_fields = parse_member_chain(input)?; - input.parse::()?; - - // Parse to field chain. - let to_fields = parse_member_chain(input)?; - input.parse::()?; - - // Parse doc string. - let doc_lit: syn::LitStr = input.parse()?; - let doc = doc_lit.value(); - if let Err(e) = validate_comment(&doc) { - return Err(syn::Error::new(doc_lit.span(), e)); - } - - Ok(ConstantDef { - doc, - name, - kind: ConstantKind::RelativeOffset { - ty: None, - from_fields, - to_fields, - }, - }) - }) -} - -/// Parse a dotted member chain: `field.sub.nested`. -fn parse_member_chain(input: syn::parse::ParseStream) -> syn::Result> { - let mut fields = Vec::new(); - let first: Ident = input.parse()?; - fields.push(syn::Member::Named(first)); - while input.peek(syn::Token![.]) { - input.parse::()?; - let member: Ident = input.parse()?; - fields.push(syn::Member::Named(member)); - } - Ok(fields) -} - -/// Resolve a constant name: use the explicit name if provided, or derive -/// from the field name via SCREAMING_SNAKE_CASE. -fn resolve_name(explicit: Option, field_ident: &Ident) -> Ident { - explicit.unwrap_or_else(|| { - Ident::new( - &field_ident.to_string().to_shouty_snake_case(), - field_ident.span(), - ) - }) -} - -/// Build `ConstantDef`s from a single field's attributes. -fn field_constants( - field: &syn::Field, - field_doc: &Option, - frame_name: &str, -) -> syn::Result> { - let mut defs = Vec::new(); - let field_ident = field.ident.as_ref().expect("frame fields must be named"); - let span = field_ident.span(); - - for attr in &field.attrs { - if !is_field_const_attr(attr) { - continue; - } - - let kind_name = attr.path().get_ident().unwrap().to_string(); - - let parsed = parse_field_const_attr(attr)?; - - match kind_name.as_str() { - OFFSET => match parsed { - FieldAttrForm::Primary { name, doc_override } => { - let name = resolve_name(name, field_ident); - let doc = resolve_doc(doc_override, field_doc, &name)?; - validate_constant_name(&name)?; - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::FrameOffset { - fields: vec![syn::Member::Named(field_ident.clone())], - }, - }); - } - FieldAttrForm::SubField { - name, - sub_fields, - doc, - } => { - validate_constant_name(&name)?; - validate_constant_doc(&doc, attr)?; - let mut fields = vec![syn::Member::Named(field_ident.clone())]; - fields.extend(sub_fields); - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::FrameOffset { fields }, - }); - } - }, - UNALIGNED_OFFSET => match parsed { - FieldAttrForm::Primary { name, doc_override } => { - let name = resolve_name(name, field_ident); - let doc = resolve_doc(doc_override, field_doc, &name)?; - validate_constant_name(&name)?; - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::UnalignedFrameOffset { - fields: vec![syn::Member::Named(field_ident.clone())], - }, - }); - } - FieldAttrForm::SubField { - name, - sub_fields, - doc, - } => { - validate_constant_name(&name)?; - validate_constant_doc(&doc, attr)?; - let mut fields = vec![syn::Member::Named(field_ident.clone())]; - fields.extend(sub_fields); - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::UnalignedFrameOffset { fields }, - }); - } - }, - PUBKEY_OFFSETS => match parsed { - FieldAttrForm::Primary { name, doc_override } => { - let name = resolve_name(name, field_ident); - let doc = resolve_doc(doc_override, field_doc, &name)?; - validate_constant_name(&name)?; - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::FramePubkeyOffsets { - fields: vec![syn::Member::Named(field_ident.clone())], - }, - }); - } - FieldAttrForm::SubField { - name, - sub_fields, - doc, - } => { - validate_constant_name(&name)?; - validate_constant_doc(&doc, attr)?; - let mut fields = vec![syn::Member::Named(field_ident.clone())]; - fields.extend(sub_fields); - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::FramePubkeyOffsets { fields }, - }); - } - }, - UNALIGNED_PUBKEY_OFFSETS => match parsed { - FieldAttrForm::Primary { .. } => { - return Err(syn::Error::new_spanned( - attr, - "unaligned_pubkey_offsets requires sub-field form: \ - #[unaligned_pubkey_offsets(NAME, subfield, \"doc\")]", - )); - } - FieldAttrForm::SubField { - name, - sub_fields, - doc, - } => { - validate_constant_name(&name)?; - validate_constant_doc(&doc, attr)?; - let mut fields = vec![syn::Member::Named(field_ident.clone())]; - fields.extend(sub_fields); - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::UnalignedFramePubkeyOffsets { fields }, - }); - } - }, - SIGNER_SEEDS => { - let (name, doc) = primary_only(parsed, attr, field_ident, field_doc)?; - let field_names = - shared_state::lookup_signer_seed_fields(frame_name, &field_ident.to_string()) - .map_err(|e| syn::Error::new(span, e))?; - - let seeds: Vec = field_names.iter().map(|n| Ident::new(n, span)).collect(); - - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::SignerSeeds { - parent_field: field_ident.clone(), - seeds, - }, - }); - } - CPI_ACCOUNTS => { - let (name, doc) = primary_only(parsed, attr, field_ident, field_doc)?; - let field_names = - shared_state::lookup_cpi_account_fields(frame_name, &field_ident.to_string()) - .map_err(|e| syn::Error::new(span, e))?; - - let accounts: Vec = - field_names.iter().map(|n| Ident::new(n, span)).collect(); - - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::CpiAccounts { - parent_field: field_ident.clone(), - accounts, - }, - }); - } - SOL_INSTRUCTION => { - let (name, doc) = primary_only(parsed, attr, field_ident, field_doc)?; - defs.push(ConstantDef { - doc, - name, - kind: ConstantKind::SolInstruction { - fields: vec![syn::Member::Named(field_ident.clone())], - }, - }); - } - _ => {} - } - } - - Ok(defs) -} - -/// Extract name and doc from a primary-only attribute, erroring on sub-field form. -fn primary_only( - parsed: FieldAttrForm, - attr: &syn::Attribute, - field_ident: &Ident, - field_doc: &Option, -) -> syn::Result<(Ident, String)> { - match parsed { - FieldAttrForm::Primary { name, doc_override } => { - let name = resolve_name(name, field_ident); - let doc = resolve_doc(doc_override, field_doc, &name)?; - validate_constant_name(&name)?; - Ok((name, doc)) - } - FieldAttrForm::SubField { .. } => Err(syn::Error::new_spanned( - attr, - "this attribute only supports primary form: #[kind] or #[kind(NAME)]", - )), - } -} - -/// Resolve the doc for a primary constant: use override if provided, else field doc. -fn resolve_doc( - doc_override: Option, - field_doc: &Option, - name: &Ident, -) -> syn::Result { - let doc = doc_override.or_else(|| field_doc.clone()).ok_or_else(|| { - syn::Error::new( - name.span(), - "field must have a /// doc comment or the attribute must include a doc string", - ) - })?; - if let Err(e) = validate_comment(&doc) { - return Err(syn::Error::new(name.span(), e)); - } - Ok(doc) -} - -fn validate_constant_name(name: &Ident) -> syn::Result<()> { - validate_name(&name.to_string()).map_err(|e| syn::Error::new(name.span(), e)) -} - -fn validate_constant_doc(doc: &str, attr: &syn::Attribute) -> syn::Result<()> { - validate_comment(doc).map_err(|e| syn::Error::new_spanned(attr, e)) -} - -/// Strip custom attributes from fields, returning cleaned fields. -fn strip_field_attrs(fields: &syn::Fields) -> syn::Fields { - let mut fields = fields.clone(); - if let syn::Fields::Named(ref mut named) = fields { - for field in &mut named.named { - field.attrs.retain(|a| !is_field_const_attr(a)); - } - } - fields -} - -/// Expand `#[frame]` or `#[frame("mod_name")]` on a struct. -/// -/// When no `#[inject]` is present on the struct, this behaves as before: -/// applies `#[repr(C, align(8))]` and asserts the struct fits in one -/// SBPF stack frame. -/// -/// When `#[inject]` is present, it also generates a constant group module -/// from field-level and struct-level constant attributes, eliminating the -/// need for a separate `constant_group!` invocation. -pub fn expand(mod_name: Option, input: &syn::ItemStruct) -> proc_macro2::TokenStream { - let vis = &input.vis; - let ident = &input.ident; - let generics = &input.generics; - let semi = &input.semi_token; - let max = sbpf_config::stack_frame_size(); - - // Register frame metadata in shared state first (needed for lookups). - let field_types: Vec<(String, String)> = input - .fields - .iter() - .filter_map(|f| { - let name = f.ident.as_ref()?.to_string(); - let ty = type_name(&f.ty); - Some((name, ty)) - }) - .collect(); - let doc = extract_doc_comment(&input.attrs).unwrap_or_default(); - shared_state::register_frame(&ident.to_string(), field_types, doc.clone()); - - // Strip custom struct-level attributes from emitted output. - let struct_attrs: Vec<_> = input - .attrs - .iter() - .filter(|a| !is_struct_const_attr(a)) - .collect(); - - // Strip custom field-level attributes. - let stripped_fields = strip_field_attrs(&input.fields); - - let struct_def = quote! { - #(#struct_attrs)* - #[repr(C, align(8))] - #vis struct #ident #generics #stripped_fields #semi - - const _: () = assert!( - core::mem::size_of::<#ident>() <= #max, - "frame struct must fit within one SBPf stack frame (4096 bytes)", - ); - }; - - // Check whether constant group generation is requested. - let target = extract_inject_target(&input.attrs); - if target.is_none() { - return struct_def; - } - let target = target.unwrap(); - let prefix = extract_attr_string(&input.attrs, "prefix"); - let mod_name = mod_name.unwrap_or_else(|| { - panic!( - "#[frame] with #[inject] requires a module name argument, \ - e.g. #[frame(\"frame\")]" - ) - }); - let mod_ident = Ident::new(&mod_name, Span::call_site()); - let frame_name = ident.to_string(); - - // Validate the group doc comment if present. - if !doc.is_empty() - && let Err(e) = validate_comment(&doc) - { - return syn::Error::new_spanned(ident, e).to_compile_error(); - } - - // Collect constants from field attributes. - let mut constants = Vec::new(); - for field in &input.fields { - let field_doc = extract_doc_comment(&field.attrs); - match field_constants(field, &field_doc, &frame_name) { - Ok(defs) => constants.extend(defs), - Err(e) => return e.to_compile_error(), - } - } - - // Collect relative_offset constants from struct-level attributes. - for attr in &input.attrs { - if attr.path().is_ident(RELATIVE_OFFSET) { - match parse_relative_offset_attr(attr) { - Ok(def) => constants.push(def), - Err(e) => return e.to_compile_error(), - } - } - } - - // Build ConstantGroupInput and expand via the existing codegen. - let frame_type: syn::Path = syn::parse_quote!(#ident); - let group_input = ConstantGroupInput { - target, - prefix, - frame_type: Some(frame_type), - doc, - mod_name: mod_ident, - constants, - }; - let group_module = constant_group::expand(&group_input); - - quote! { - #struct_def - #group_module - } -} diff --git a/macros/src/frame/field_constants.rs b/macros/src/frame/field_constants.rs new file mode 100644 index 0000000..a13e4de --- /dev/null +++ b/macros/src/frame/field_constants.rs @@ -0,0 +1,210 @@ +use heck::ToShoutySnakeCase; +use syn::Ident; + +use crate::attrs::{extract_doc_comment, validate_comment, validate_name}; +use crate::constant_group::{ConstantDef, ConstantKind}; +use crate::shared_state; + +use super::parse::{self, FieldAttrForm}; + +// region: attribute names +const OFFSET: &str = "offset"; +const UNALIGNED_OFFSET: &str = "unaligned_offset"; +const PUBKEY_OFFSETS: &str = "pubkey_offsets"; +const UNALIGNED_PUBKEY_OFFSETS: &str = "unaligned_pubkey_offsets"; +const SIGNER_SEEDS: &str = "signer_seeds"; +const CPI_ACCOUNTS: &str = "cpi_accounts"; +const SOL_INSTRUCTION: &str = "sol_instruction"; + +pub(crate) const FIELD_ATTR_NAMES: &[&str] = &[ + OFFSET, + UNALIGNED_OFFSET, + PUBKEY_OFFSETS, + UNALIGNED_PUBKEY_OFFSETS, + SIGNER_SEEDS, + CPI_ACCOUNTS, + SOL_INSTRUCTION, +]; +// endregion: attribute names + +/// Returns true if `attr` is one of the custom field-level constant attributes. +pub(crate) fn is_field_const_attr(attr: &syn::Attribute) -> bool { + FIELD_ATTR_NAMES + .iter() + .any(|name| attr.path().is_ident(name)) +} + +/// Build `ConstantDef`s from a single field's attributes. +pub(crate) fn field_constants( + field: &syn::Field, + frame_name: &str, +) -> syn::Result> { + let mut defs = Vec::new(); + let field_ident = field.ident.as_ref().expect("frame fields must be named"); + let field_doc = extract_doc_comment(&field.attrs); + let span = field_ident.span(); + + for attr in &field.attrs { + if !is_field_const_attr(attr) { + continue; + } + + let kind_name = attr.path().get_ident().unwrap().to_string(); + let parsed = parse::parse_field_const_attr(attr)?; + + match kind_name.as_str() { + // Offset-style kinds: all share the same Primary/SubField structure, + // differing only in which ConstantKind variant they produce. + OFFSET | UNALIGNED_OFFSET | PUBKEY_OFFSETS | UNALIGNED_PUBKEY_OFFSETS => match parsed { + FieldAttrForm::Primary { .. } if kind_name == UNALIGNED_PUBKEY_OFFSETS => { + return Err(syn::Error::new_spanned( + attr, + "unaligned_pubkey_offsets requires sub-field form: \ + #[unaligned_pubkey_offsets(NAME, subfield, \"doc\")]", + )); + } + FieldAttrForm::Primary { name, doc_override } => { + let name = resolve_name(name, field_ident); + let doc = resolve_doc(doc_override, &field_doc, &name)?; + validate_constant_name(&name)?; + defs.push(ConstantDef { + doc, + name, + kind: offset_kind( + &kind_name, + vec![syn::Member::Named(field_ident.clone())], + ), + }); + } + FieldAttrForm::SubField { + name, + sub_fields, + doc, + } => { + validate_constant_name(&name)?; + validate_constant_doc(&doc, attr)?; + let mut fields = vec![syn::Member::Named(field_ident.clone())]; + fields.extend(sub_fields); + defs.push(ConstantDef { + doc, + name, + kind: offset_kind(&kind_name, fields), + }); + } + }, + SIGNER_SEEDS => { + let (name, doc) = primary_only(parsed, attr, field_ident, &field_doc)?; + let field_names = + shared_state::lookup_signer_seed_fields(frame_name, &field_ident.to_string()) + .map_err(|e| syn::Error::new(span, e))?; + let seeds: Vec = field_names.iter().map(|n| Ident::new(n, span)).collect(); + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::SignerSeeds { + parent_field: field_ident.clone(), + seeds, + }, + }); + } + CPI_ACCOUNTS => { + let (name, doc) = primary_only(parsed, attr, field_ident, &field_doc)?; + let field_names = + shared_state::lookup_cpi_account_fields(frame_name, &field_ident.to_string()) + .map_err(|e| syn::Error::new(span, e))?; + let accounts: Vec = + field_names.iter().map(|n| Ident::new(n, span)).collect(); + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::CpiAccounts { + parent_field: field_ident.clone(), + accounts, + }, + }); + } + SOL_INSTRUCTION => { + let (name, doc) = primary_only(parsed, attr, field_ident, &field_doc)?; + defs.push(ConstantDef { + doc, + name, + kind: ConstantKind::SolInstruction { + fields: vec![syn::Member::Named(field_ident.clone())], + }, + }); + } + _ => {} + } + } + + Ok(defs) +} + +/// Map an offset-style attribute name to its `ConstantKind` variant. +fn offset_kind(kind_name: &str, fields: Vec) -> ConstantKind { + match kind_name { + OFFSET => ConstantKind::FrameOffset { fields }, + UNALIGNED_OFFSET => ConstantKind::UnalignedFrameOffset { fields }, + PUBKEY_OFFSETS => ConstantKind::FramePubkeyOffsets { fields }, + UNALIGNED_PUBKEY_OFFSETS => ConstantKind::UnalignedFramePubkeyOffsets { fields }, + _ => unreachable!(), + } +} + +/// Extract name and doc from a primary-only attribute, erroring on sub-field form. +fn primary_only( + parsed: FieldAttrForm, + attr: &syn::Attribute, + field_ident: &Ident, + field_doc: &Option, +) -> syn::Result<(Ident, String)> { + match parsed { + FieldAttrForm::Primary { name, doc_override } => { + let name = resolve_name(name, field_ident); + let doc = resolve_doc(doc_override, field_doc, &name)?; + validate_constant_name(&name)?; + Ok((name, doc)) + } + FieldAttrForm::SubField { .. } => Err(syn::Error::new_spanned( + attr, + "this attribute only supports primary form: #[kind] or #[kind(NAME)]", + )), + } +} + +/// Resolve a constant name: use the explicit name if provided, or derive +/// from the field name via SCREAMING_SNAKE_CASE. +fn resolve_name(explicit: Option, field_ident: &Ident) -> Ident { + explicit.unwrap_or_else(|| { + Ident::new( + &field_ident.to_string().to_shouty_snake_case(), + field_ident.span(), + ) + }) +} + +/// Resolve the doc for a primary constant: use override if provided, else field doc. +fn resolve_doc( + doc_override: Option, + field_doc: &Option, + name: &Ident, +) -> syn::Result { + let doc = doc_override.or_else(|| field_doc.clone()).ok_or_else(|| { + syn::Error::new( + name.span(), + "field must have a /// doc comment or the attribute must include a doc string", + ) + })?; + if let Err(e) = validate_comment(&doc) { + return Err(syn::Error::new(name.span(), e)); + } + Ok(doc) +} + +fn validate_constant_name(name: &Ident) -> syn::Result<()> { + validate_name(&name.to_string()).map_err(|e| syn::Error::new(name.span(), e)) +} + +fn validate_constant_doc(doc: &str, attr: &syn::Attribute) -> syn::Result<()> { + validate_comment(doc).map_err(|e| syn::Error::new_spanned(attr, e)) +} diff --git a/macros/src/frame/mod.rs b/macros/src/frame/mod.rs new file mode 100644 index 0000000..1b7a88f --- /dev/null +++ b/macros/src/frame/mod.rs @@ -0,0 +1,163 @@ +mod field_constants; +mod parse; + +use proc_macro2::Span; +use quote::quote; +use syn::Ident; + +use crate::attrs::{ + extract_attr_string, extract_doc_comment, extract_inject_target, validate_comment, +}; +use crate::constant_group::parse::ConstantGroupInput; +use crate::constant_group::{self}; +use crate::sbpf_config; +use crate::shared_state; + +use field_constants::is_field_const_attr; + +// region: struct attribute names +const RELATIVE_OFFSET: &str = "relative_offset"; + +const STRUCT_ATTR_NAMES: &[&str] = &["inject", "prefix", RELATIVE_OFFSET]; +// endregion: struct attribute names + +/// Returns true if `attr` is a custom struct-level attribute consumed by `#[frame]`. +fn is_struct_const_attr(attr: &syn::Attribute) -> bool { + STRUCT_ATTR_NAMES + .iter() + .any(|name| attr.path().is_ident(name)) +} + +/// Extract the last path segment from a type (e.g. `crate::Foo` -> `Foo`). +fn type_name(ty: &syn::Type) -> String { + match ty { + syn::Type::Path(tp) => tp + .path + .segments + .last() + .map(|s| s.ident.to_string()) + .unwrap_or_default(), + _ => String::new(), + } +} + +/// Strip custom attributes from fields, returning cleaned fields. +fn strip_field_attrs(fields: &syn::Fields) -> syn::Fields { + let mut fields = fields.clone(); + if let syn::Fields::Named(ref mut named) = fields { + for field in &mut named.named { + field.attrs.retain(|a| !is_field_const_attr(a)); + } + } + fields +} + +/// Expand `#[frame]` or `#[frame("mod_name")]` on a struct. +/// +/// When no `#[inject]` is present on the struct, this behaves as before: +/// applies `#[repr(C, align(8))]` and asserts the struct fits in one +/// SBPF stack frame. +/// +/// When `#[inject]` is present, it also generates a constant group module +/// from field-level and struct-level constant attributes, eliminating the +/// need for a separate `constant_group!` invocation. +pub fn expand(mod_name: Option, input: &syn::ItemStruct) -> proc_macro2::TokenStream { + let vis = &input.vis; + let ident = &input.ident; + let generics = &input.generics; + let semi = &input.semi_token; + let max = sbpf_config::stack_frame_size(); + + // Register frame metadata in shared state first (needed for lookups). + let field_types: Vec<(String, String)> = input + .fields + .iter() + .filter_map(|f| { + let name = f.ident.as_ref()?.to_string(); + let ty = type_name(&f.ty); + Some((name, ty)) + }) + .collect(); + let doc = extract_doc_comment(&input.attrs).unwrap_or_default(); + shared_state::register_frame(&ident.to_string(), field_types, doc.clone()); + + // Strip custom struct-level attributes from emitted output. + let struct_attrs: Vec<_> = input + .attrs + .iter() + .filter(|a| !is_struct_const_attr(a)) + .collect(); + + // Strip custom field-level attributes. + let stripped_fields = strip_field_attrs(&input.fields); + + let struct_def = quote! { + #(#struct_attrs)* + #[repr(C, align(8))] + #vis struct #ident #generics #stripped_fields #semi + + const _: () = assert!( + core::mem::size_of::<#ident>() <= #max, + "frame struct must fit within one SBPf stack frame (4096 bytes)", + ); + }; + + // Check whether constant group generation is requested. + let target = extract_inject_target(&input.attrs); + if target.is_none() { + return struct_def; + } + let target = target.unwrap(); + let prefix = extract_attr_string(&input.attrs, "prefix"); + let mod_name = mod_name.unwrap_or_else(|| { + panic!( + "#[frame] with #[inject] requires a module name argument, \ + e.g. #[frame(\"frame\")]" + ) + }); + let mod_ident = Ident::new(&mod_name, Span::call_site()); + let frame_name = ident.to_string(); + + // Validate the group doc comment if present. + if !doc.is_empty() + && let Err(e) = validate_comment(&doc) + { + return syn::Error::new_spanned(ident, e).to_compile_error(); + } + + // Collect constants from field attributes. + let mut constants = Vec::new(); + for field in &input.fields { + match field_constants::field_constants(field, &frame_name) { + Ok(defs) => constants.extend(defs), + Err(e) => return e.to_compile_error(), + } + } + + // Collect relative_offset constants from struct-level attributes. + for attr in &input.attrs { + if attr.path().is_ident(RELATIVE_OFFSET) { + match parse::parse_relative_offset_attr(attr) { + Ok(def) => constants.push(def), + Err(e) => return e.to_compile_error(), + } + } + } + + // Build ConstantGroupInput and expand via the existing codegen. + let frame_type: syn::Path = syn::parse_quote!(#ident); + let group_input = ConstantGroupInput { + target, + prefix, + frame_type: Some(frame_type), + doc, + mod_name: mod_ident, + constants, + }; + let group_module = constant_group::expand(&group_input); + + quote! { + #struct_def + #group_module + } +} diff --git a/macros/src/frame/parse.rs b/macros/src/frame/parse.rs new file mode 100644 index 0000000..0cd8160 --- /dev/null +++ b/macros/src/frame/parse.rs @@ -0,0 +1,115 @@ +use syn::Ident; + +use crate::attrs::{validate_comment, validate_name}; +use crate::constant_group::{ConstantDef, ConstantKind}; + +/// Result of parsing a field constant attribute. +pub(crate) enum FieldAttrForm { + /// `#[kind]`, `#[kind(NAME)]`, or `#[kind(NAME, "doc")]`. + Primary { + /// `None` when the name should be inferred from the field name. + name: Option, + doc_override: Option, + }, + /// `#[kind(NAME, subfield.nested, "doc")]`. + SubField { + name: Ident, + sub_fields: Vec, + doc: String, + }, +} + +/// Parse a field constant attribute in a single pass. +/// +/// Forms: +/// - Empty args (`#[kind]`) -> `Primary { name: None }` +/// - `#[kind(NAME)]` -> `Primary { name: Some(NAME) }` +/// - `#[kind(NAME, "doc")]` -> `Primary { name: Some(NAME), doc_override }` +/// - `#[kind(NAME, subfield, "doc")]` -> `SubField` +pub(crate) fn parse_field_const_attr(attr: &syn::Attribute) -> syn::Result { + let has_args = matches!(&attr.meta, syn::Meta::List(_)); + if !has_args { + return Ok(FieldAttrForm::Primary { + name: None, + doc_override: None, + }); + } + + attr.parse_args_with(|input: syn::parse::ParseStream| { + let name: Ident = input.parse()?; + + if input.is_empty() { + return Ok(FieldAttrForm::Primary { + name: Some(name), + doc_override: None, + }); + } + + input.parse::()?; + + if input.peek(syn::LitStr) { + let lit: syn::LitStr = input.parse()?; + return Ok(FieldAttrForm::Primary { + name: Some(name), + doc_override: Some(lit.value()), + }); + } + + // Sub-field form: parse field chain then doc string. + let sub_fields = parse_member_chain(input)?; + input.parse::()?; + let doc_lit: syn::LitStr = input.parse()?; + + Ok(FieldAttrForm::SubField { + name, + sub_fields, + doc: doc_lit.value(), + }) + }) +} + +/// Parse a `#[relative_offset(NAME, from, to, "doc")]` struct-level attribute. +pub(crate) fn parse_relative_offset_attr(attr: &syn::Attribute) -> syn::Result { + attr.parse_args_with(|input: syn::parse::ParseStream| { + let name: Ident = input.parse()?; + if let Err(e) = validate_name(&name.to_string()) { + return Err(syn::Error::new(name.span(), e)); + } + input.parse::()?; + + let from_fields = parse_member_chain(input)?; + input.parse::()?; + + let to_fields = parse_member_chain(input)?; + input.parse::()?; + + let doc_lit: syn::LitStr = input.parse()?; + let doc = doc_lit.value(); + if let Err(e) = validate_comment(&doc) { + return Err(syn::Error::new(doc_lit.span(), e)); + } + + Ok(ConstantDef { + doc, + name, + kind: ConstantKind::RelativeOffset { + ty: None, + from_fields, + to_fields, + }, + }) + }) +} + +/// Parse a dotted member chain: `field.sub.nested`. +fn parse_member_chain(input: syn::parse::ParseStream) -> syn::Result> { + let mut fields = Vec::new(); + let first: Ident = input.parse()?; + fields.push(syn::Member::Named(first)); + while input.peek(syn::Token![.]) { + input.parse::()?; + let member: Ident = input.parse()?; + fields.push(syn::Member::Named(member)); + } + Ok(fields) +} From 8fd7166927e0ecdd4e08cec0af8efb2b51a495bc Mon Sep 17 00:00:00 2001 From: Alex Kahn <43892045+alnoki@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:45:38 -0700 Subject: [PATCH 8/8] Update doc comment --- macros/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index aaacc7a..596666a 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -180,13 +180,13 @@ pub fn instruction_data(attr: TokenStream, item: TokenStream) -> TokenStream { /// /// Stack frame for REGISTER-MARKET. /// pub struct RegisterMarketFrame { /// /// Pointer to token program address. -/// #[offset(TOKEN_PROGRAM_ID)] +/// #[offset] /// pub token_program_id: *const Address, /// /// PDA signer seeds. -/// #[signer_seeds(PDA_SEEDS)] +/// #[signer_seeds] /// pub pda_seeds: PdaSignerSeeds, /// /// Bump seed. -/// #[offset(BUMP)] +/// #[offset] /// pub bump: u8, /// } /// ```