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.