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/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/interface/src/market.rs b/interface/src/market.rs index ecd1ba9..b7c94f6 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 { @@ -116,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! { @@ -131,10 +130,9 @@ cpi_accounts! { } } -// region: register_market_stack // 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). @@ -146,126 +144,140 @@ 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] pub token_program_id: *const Address, + /// Pointer to program ID in input buffer. + #[offset] pub program_id: *const Address, + /// Saved input buffer pointer. + #[offset] pub input: u64, + /// Saved input_shifted pointer. + #[offset] pub input_shifted: u64, + /// From Rent sysvar. + #[offset] 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. + #[offset] pub token_account_data_size: u64, + /// Pointer to mint account for vault initialization. + #[offset] pub mint: *const RuntimeAccount, + /// Pointer to Rent sysvar account. + #[offset] pub rent: *const RuntimeAccount, - /// Signer seeds for PDA derivation and CPI signing. - pub pda_seeds: PDASignerSeeds, - /// From `sol_try_find_program_address`. + + /// PDA signer seeds. + #[signer_seeds] + pub pda_seeds: SignerSeeds, + + /// PDA address. + #[pubkey_offsets] pub pda: Address, - /// System Program pubkey, zero-initialized on stack + + /// System Program pubkey. + #[pubkey_offsets] pub system_program_pubkey: Address, - /// Pointer to System Program ID in input buffer. + + /// System Program ID in input buffer. + #[offset] 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] 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. - pub initialize_account_2_data: InitializeAccount2, + /// GetAccountDataSize CPI instruction data. + #[unaligned_offset] pub get_account_data_size_data: u8, - /// CPI accounts for CreateAccount and InitializeAccount2. + + /// 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( + 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, + + /// 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] pub bump: u8, - /// Vault index for vault PDA derivation. - pub vault_index: u8, - /// Whether the current token program is Token 2022 (zero-initialized on stack). - 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 GetAccountDataSize CPI, to check token account data size at runtime. - 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 deleted file mode 100644 index 2b2b17b..0000000 --- a/macros/src/frame.rs +++ /dev/null @@ -1,55 +0,0 @@ -use quote::quote; - -use crate::attrs::extract_doc_comment; -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(), - } -} - -/// 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; - 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. - 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); - - quote! { - #(#attrs)* - #[repr(C, align(8))] - #vis struct #ident #generics #fields #semi - - const _: () = assert!( - core::mem::size_of::<#ident>() <= #max, - "frame struct must fit within one SBPf stack frame (4096 bytes)", - ); - } -} 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) +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 5bd9aa5..596666a 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! { @@ -160,19 +167,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] +/// pub token_program_id: *const Address, +/// /// PDA signer seeds. +/// #[signer_seeds] /// pub pda_seeds: PdaSignerSeeds, -/// pub pda: Address, +/// /// Bump seed. +/// #[offset] /// 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. 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 9d11197..0335aa3 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 @@ -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.