From 0ef8f12266962e380318688b52277e78c0b37aea Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 14 Aug 2026 10:55:34 +0200 Subject: [PATCH 1/2] feat: a no-op anchor declares embedded mode The role kwarg was the only embedded declaration freeze had, and a marked freeze_slot field without it compiled silently as dedicated mode with a dead slot field. The extension now declares freeze_initialize as its anchor. The attribute expands to nothing, the slot stays born vacant, and the marker goes bare in both modes. The missing_freeze_initialize fixture pins the refusal from the vacant-slot side. ADR-0013 records the decision. --- docs/lez/extensions/admin-authority.md | 11 ++++--- .../build-a-spel-extension-library.md | 33 ++++++++++++++++--- docs/lez/extensions/freeze-authority.md | 13 +++++--- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/lez/extensions/admin-authority.md b/docs/lez/extensions/admin-authority.md index 680eb124..22e913e7 100644 --- a/docs/lez/extensions/admin-authority.md +++ b/docs/lez/extensions/admin-authority.md @@ -170,7 +170,7 @@ The PDA must already exist on chain as a claimed account, an unclaimed candidate ## Embedded mode, the admin slot inside your own account -Instead of a dedicated Config PDA, the admin slot can live inside one of your program's own accounts at a byte offset. Declared once, program wide, on the marker: +Instead of a dedicated Config PDA, the admin slot can live inside one of your program's own accounts. Embedded mode is two declarations, each on the thing it describes: `#[admin_slot]` on the field that holds the slot, and `#[admin_initialize]` on the instruction that creates the account. The module marker stays bare, and the offset is derived from the marked field: ```rust #[account_type] @@ -183,7 +183,7 @@ pub struct ProgramConfig { } #[lez_program] -#[admin_authority(admin_config = config, offset = 32)] +#[admin_authority] mod my_program { use admin_authority::admin_initialize; @@ -217,12 +217,13 @@ impl ProgramConfig { What changes: - **No `admin_initialize` instruction.** Mark your own account-creating instruction with `#[admin_initialize]` instead. The bootstrap is injected, the caller is installed as admin in the same transaction that creates the account, and the slot is born initialized, so the initialization window from the warning above does not exist in embedded mode. An account created without the bootstrap is born renounced, permanently. -- **`#[admin_slot]` keeps the layout honest.** The field marker derives an `ADMIN_SLOT_OFFSET` const and a layout test, and the build fails if the marker position and the `offset = ...` declaration ever disagree, for example after a field is added above the slot. +- **`#[admin_slot]` declares the offset.** The field marker derives an `ADMIN_SLOT_OFFSET` const and a layout test, and the framework reads the slot through the const. A field added above the slot moves the derived offset with it instead of silently moving the window. +- **The declarations must agree.** A struct carrying `#[admin_slot]` with no fn carrying `#[admin_initialize]` refuses to build, the account would ship born renounced. An `#[admin_initialize]` fn with no marked field refuses too. Neither declaration is plain dedicated mode. When the anchored instruction creates several accounts, name the embedding one with `#[admin_initialize(admin_config = config)]`. - **Everything retargets.** Gates read the slot at the declared offset from your account, `admin_transfer` and `admin_renounce` splice only the 32 byte window and leave your neighboring fields untouched, and the IDL shows your account wherever the dedicated PDA used to appear. -- **The offset is never in a transaction.** It compiles into the program as a literal. The IDL carries no offset argument, and writing `admin_config = ...` or `offset = ...` on a gate by hand is a compile error in embedded mode. +- **The offset is never in a transaction.** It compiles into the program. The IDL carries no offset argument, and writing `admin_config = ...` or `offset = ...` on the marker or on a gate by hand is a compile error. - **One account fewer** on every gated transaction, the slot travels with state you were already passing. -The embedded `AdminConfig` field must sit at the declared offset with only fixed-size fields before it. The library repository ships a reference sample (`admin-authority-sample-embedded`) with the layout, tests, and a committed dry-run walkthrough. +The embedded `AdminConfig` field must have only fixed-size fields before it so the offset const can be derived. The library repository ships a reference sample (`admin-authority-sample-embedded`) with the layout, tests, and a committed dry-run walkthrough. ## Renounce admin permanently diff --git a/docs/lez/extensions/build-a-spel-extension-library.md b/docs/lez/extensions/build-a-spel-extension-library.md index 86fa3c11..85d80eb4 100644 --- a/docs/lez/extensions/build-a-spel-extension-library.md +++ b/docs/lez/extensions/build-a-spel-extension-library.md @@ -221,12 +221,14 @@ The hook is opt-in, omit `wrap_instructions` from your metadata and the framewor ## Embedded mode (optional) -An extension whose per-program state is one fixed-size slot can let consumers embed that slot inside one of their own accounts instead of a dedicated PDA. The consumer declares it on your marker, role name plus byte offset: +An extension whose per-program state is one fixed-size slot can let consumers embed that slot inside one of their own accounts instead of a dedicated PDA. A consumer declares it on your marker with the role name, and the byte offset is derived from the slot field marker described below: ```rust -#[my_extension(my_state = config, offset = 32)] +#[my_extension(my_state = config)] ``` +An extension that declares an anchor (see born-initialized slots below) drops even the kwarg, embedded mode is inferred from the consumer's anchored instruction and the marker stays bare in both modes. + To support this as an author: ship windowed state accessors that splice only your slot's byte window (`decode_at`, `write_to_at`, `bootstrap_at` and friends), give the affected instruction functions a trailing `offset: usize` parameter, and declare it as a bound arg so the framework fills it at the dispatch call site as a compile-time literal. Bound args must be the trailing parameters of the function, in the same order as their metadata blocks. Any other position is a hard error at discovery naming the function, because the framework always appends the literals last: ```toml @@ -242,9 +244,22 @@ default = 0 `embedded.skip` names instructions not emitted in embedded mode (typically your initializer, the consumer's own account creation replaces it). `state_type` is mandatory for embedded mode and names the Rust type that lives in your window. The framework reads the window's size through it and emits a compile-time assert per pair of extensions embedding into the same consumer account, so genuinely overlapping windows refuse to compile instead of silently corrupting each other. Discovery fails closed when an embedded extension omits it. Bound args are stripped from the IDL and the transaction entirely, a caller can never supply an offset, and dedicated mode is the degenerate case offset 0 through the declared default. `from` also accepts a peer marker's kwarg (`from = "admin_authority::offset"`) so an extension can read state a peer embedded, without depending on the peer's crate. A missing marker or kwarg without a declared default is a hard compile error, never a silent zero. -**Slot field markers.** The framework derives a marker name from your role, the role name minus a `_config` suffix plus `_slot` (role `admin_config` gives `#[admin_slot]`, role `my_state` gives `#[my_state_slot]`). A consumer who puts that marker on the embedding field of an `#[account_type]` struct gets a derived `_OFFSET` const, an emitted layout test, and a compile-time assert that the derived offset equals the `offset = ...` declared on your marker, so a field added above the slot fails the build instead of silently moving the window. Adoption is optional (no marker, no check), and two structs carrying the same marker is a compile error. Nothing to implement on your side, the mechanism ships with `#[account_type]`, but document the marker name your role produces. +**Slot field markers.** The framework derives a marker name from your role, the role name minus a `_config` suffix plus `_slot` (role `admin_config` gives `#[admin_slot]`, role `my_state` gives `#[my_state_slot]`). The consumer puts that marker on the embedding field of an `#[account_type]` struct, and the field's position becomes the offset: a derived `_OFFSET` const the framework reads the slot through, plus an emitted layout test. A field added above the slot moves the derived offset with it. A consumer may still write a literal `offset = ...` on your marker, then a compile-time assert checks it against the marked field's position and a disagreement fails the build. A derivation with no marked field is a compile error spelling out the fix, and two structs carrying the same marker is a compile error naming both. Nothing to implement on your side, the mechanism ships with `#[account_type]`, but document the marker name your role produces. + +**Born-initialized slots.** If your slot must never exist uninitialized (the way an admin slot without a holder is a takeover window), ship a bootstrap attribute consumers put on their own account-creating instruction (the way `admin-authority` ships `#[admin_initialize]`), and declare it as the anchor in your embedded metadata: + +```toml +[package.metadata.spel.embedded] +state_type = "my_extension::MyConfig" +anchor_attr = "my_extension_init" +anchor_role = "my_state" +``` + +A declared anchor changes how consumers adopt embedded mode. A consumer fn carrying the anchor attribute puts your extension in embedded mode, its `#[account(init)]` param is the embedding account, and the marker stays bare. Writing the role kwarg on the marker becomes a hard error, the anchor fn is the single declaration. When the anchored fn creates several accounts, the consumer names the embedding one with the same kwarg on the anchor (`#[my_extension_init(my_state = config)]`). The anchor is also the coverage gate. A struct carrying your slot marker with no anchored fn refuses to build, because the account would ship born renounced. An anchored fn with no marked field refuses too, the derivation has no carrier. Neither declaration is plain dedicated mode. + +Implement the attribute as a proc macro that injects your `bootstrap_at` call into the handler body, and declare it as an inject wrapper in metadata so your role parameters synthesize on the marked instruction like they do on gates. Reject instructions whose embedding account is not `init`, a bootstrap against an existing account is a takeover. -**Born-initialized slots.** If your slot must never exist uninitialized (the way an admin slot without a holder is a takeover window), ship a bootstrap attribute consumers put on their own account-creating instruction (the way `admin-authority` ships `#[admin_initialize]`). Implement it as a proc macro that injects your `bootstrap_at` call into the handler body, and declare it as an inject wrapper in metadata so your role parameters synthesize on the marked instruction like they do on gates. Make it embedded-mode only and let the framework stamp the location kwargs, and reject instructions whose embedding account is not `init`, a bootstrap against an existing account is a takeover. A slot that can start empty (the way freeze starts vacant and the admin appoints the first holder via transfer) needs no bootstrap attribute at all. +A slot that can start empty (the way freeze starts vacant and the admin appoints the first holder via transfer) has nothing to bootstrap, but anchoring still pays: declare the anchor pair and ship the attribute as a pure pass-through that expands to nothing. The consumer's surface goes bare like any anchored extension, and the slot-marker agreement turns hard, a marked field with no anchored instruction refuses to build instead of silently compiling dedicated mode. freeze-authority does exactly this with `#[freeze_initialize]`. An extension that declares no anchor keeps the role kwarg on the marker as its consumers' embedded declaration. When two extensions embed into the same consumer account at distinct offsets, the framework merges the duplicated account into one transaction account (listed once in the IDL with unioned constraints, cloned into each position of the call) and your instruction must emit exactly one post-state per unique account id. Same account at the same offset is a compile error. @@ -316,6 +331,16 @@ Each extension is discovered independently by its own `extension_attr`. Each con Marker order is the cross-extension ABI: the first marker's instructions and injected parameters come first in the dispatcher, the IDL, and the account order. When two extensions inject the same parameter name with identical constraints they share one account, conflicting constraints are a compile error naming both extensions. Duplicate instruction names between extensions (or an extension and a consumer function) are a compile error naming both sources. Two extensions can even embed into the same consumer account at distinct offsets, see embedded mode above. +## Account layouts in the consumer's IDL + +`#[account_type]` marks a struct or enum as an account layout, the decode catalogue entry wallets and the CLI use to read a program's accounts. The IDL splits layouts from helpers: annotated types land in `accounts`, and every type a layout references lands in `types`, collected by name without needing an annotation of its own. + +A layout reaches a consumer's IDL only from code connected to the program: the consumer's own crate, its local path dependencies, and the extensions its markers activate. Your extension's layouts ship because your instructions ship. Everything else in the dependency graph is inert, a transitive crate can never put a layout into a consumer's IDL, and its types are pulled in only by reference when something connected names them. Items a default build never compiles are screened out everywhere, so a `#[cfg(test)]` fixture is neither a layout nor an answer for a referenced type. + +Two connected crates declaring account layouts of the same name refuse to build, naming the type and both paths. Two layouts of one name would make the IDL ambiguous, and both declarations sit in code the consumer owns or activated, so the rename is theirs to make. + +In embedded mode your `state_type` is not an account of its own. The consumer's struct is the layout, and your type is described in `types` by reference. + ## Verifying your extension Build a small sample program that consumes your extension. Then: diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index 78b61185..db1448f6 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -261,12 +261,14 @@ pub struct ProgramConfig { } #[lez_program] -#[admin_authority(admin_config = config, offset = 32)] -#[freeze_authority(freeze_config = config, offset = 64)] +#[admin_authority] +#[freeze_authority] mod my_program { use admin_authority::admin_initialize; + use freeze_authority::freeze_initialize; #[admin_initialize] + #[freeze_initialize] #[instruction] pub fn initialize( #[account(init, pda = literal("program_config"))] mut config: AccountWithMetadata, @@ -281,11 +283,12 @@ mod my_program { What changes: -- **No `freeze_initialize`.** Your account-creating instruction writes the struct, and the freeze slot is born vacant: it rejects every holder-path caller until the admin appoints the first holder via `freeze_authority_transfer`, the same path that repopulates a renounced slot. There is no initialization ordering to get right because there is no initializer. The admin slot next door is bootstrapped by marking that same instruction with `#[admin_initialize]`, the caller becomes admin in the transaction that creates the account. -- **Slot markers keep the layout honest.** `#[admin_slot]` and `#[freeze_slot]` each derive an offset const and a layout test, and the build fails if a marker position and its `offset = ...` declaration ever disagree, for example after a field is added above a slot. +- **No `freeze_initialize` instruction, an anchor attribute instead.** Mark your account-creating instruction with `#[freeze_initialize]`. The attribute expands to nothing, it declares embedded mode so the module marker stays bare, and the freeze slot is born vacant: it rejects every holder-path caller until the admin appoints the first holder via `freeze_authority_transfer`, the same path that repopulates a renounced slot. There is no initialization ordering to get right because nothing initializes. The admin slot next door is bootstrapped by marking that same instruction with `#[admin_initialize]`, the caller becomes admin in the transaction that creates the account. Two anchors on one instruction compose. +- **Slot markers declare the offsets.** `#[admin_slot]` and `#[freeze_slot]` each derive an offset const and a layout test, and the framework reads each slot through its const. A field added above a slot moves the derived offset with it. There is no offset kwarg, the marked field is the only source. +- **The declarations must agree.** A struct carrying `#[freeze_slot]` with no instruction carrying `#[freeze_initialize]` refuses to build, the marked field declares embedded mode with nothing anchoring it. An anchored instruction with no marked field refuses too. Neither declaration is plain dedicated mode. - **One account per transaction.** When admin and freeze share the embedding account, management instructions that read both carry the shared account once. `freeze_authority_renounce` drops from 3 accounts to 2. - **Splice-only writes.** Freeze operations write only the 33 byte window (32 byte slot plus the frozen flag), your neighboring fields survive every toggle and transfer. -- **Offsets never appear in a transaction.** They compile into the program as literals, and the IDL carries no offset arguments. +- **Offsets never appear in a transaction.** They compile into the program, and the IDL carries no offset arguments. The library repository ships `freeze-authority-sample-embedded` with the full layout, adjacent-window tests, and a committed dry-run walkthrough. From 40ad4ffbb09ffffb04af50bb6765e5efc079c47b Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 14 Aug 2026 21:00:28 +0200 Subject: [PATCH 2/2] docs: consumers declare the initializer with the #[initialize] shorthand One attribute stands for every activated extension's anchor, expanded in marker order. The explicit attributes stay documented as the fallback for instructions that create several accounts. --- docs/lez/extensions/admin-authority.md | 8 ++++---- docs/lez/extensions/build-a-spel-extension-library.md | 2 +- docs/lez/extensions/freeze-authority.md | 5 ++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/lez/extensions/admin-authority.md b/docs/lez/extensions/admin-authority.md index 22e913e7..b257aad7 100644 --- a/docs/lez/extensions/admin-authority.md +++ b/docs/lez/extensions/admin-authority.md @@ -170,7 +170,7 @@ The PDA must already exist on chain as a claimed account, an unclaimed candidate ## Embedded mode, the admin slot inside your own account -Instead of a dedicated Config PDA, the admin slot can live inside one of your program's own accounts. Embedded mode is two declarations, each on the thing it describes: `#[admin_slot]` on the field that holds the slot, and `#[admin_initialize]` on the instruction that creates the account. The module marker stays bare, and the offset is derived from the marked field: +Instead of a dedicated Config PDA, the admin slot can live inside one of your program's own accounts. Embedded mode is two declarations, each on the thing it describes: `#[admin_slot]` on the field that holds the slot, and `#[initialize]` on the instruction that creates the account. `#[initialize]` is a shorthand for the anchor attribute of every activated extension, here it stands for `#[admin_initialize]`, and the framework expands it before anything else runs. The module marker stays bare, and the offset is derived from the marked field: ```rust #[account_type] @@ -187,7 +187,7 @@ pub struct ProgramConfig { mod my_program { use admin_authority::admin_initialize; - #[admin_initialize] + #[initialize] #[instruction] pub fn initialize( #[account(init, pda = literal("program_config"))] mut config: AccountWithMetadata, @@ -216,9 +216,9 @@ impl ProgramConfig { What changes: -- **No `admin_initialize` instruction.** Mark your own account-creating instruction with `#[admin_initialize]` instead. The bootstrap is injected, the caller is installed as admin in the same transaction that creates the account, and the slot is born initialized, so the initialization window from the warning above does not exist in embedded mode. An account created without the bootstrap is born renounced, permanently. +- **No `admin_initialize` instruction.** Mark your own account-creating instruction with `#[initialize]` instead. The bootstrap is injected, the caller is installed as admin in the same transaction that creates the account, and the slot is born initialized, so the initialization window from the warning above does not exist in embedded mode. An account created without the bootstrap is born renounced, permanently. - **`#[admin_slot]` declares the offset.** The field marker derives an `ADMIN_SLOT_OFFSET` const and a layout test, and the framework reads the slot through the const. A field added above the slot moves the derived offset with it instead of silently moving the window. -- **The declarations must agree.** A struct carrying `#[admin_slot]` with no fn carrying `#[admin_initialize]` refuses to build, the account would ship born renounced. An `#[admin_initialize]` fn with no marked field refuses too. Neither declaration is plain dedicated mode. When the anchored instruction creates several accounts, name the embedding one with `#[admin_initialize(admin_config = config)]`. +- **The declarations must agree.** A struct carrying `#[admin_slot]` with no fn carrying `#[admin_initialize]` refuses to build, the account would ship born renounced. An `#[admin_initialize]` fn with no marked field refuses too. Neither declaration is plain dedicated mode. The shorthand covers only the unambiguous shape, one `#[account(init)]` param on the fn. When the instruction creates several accounts, write the explicit attr and name the embedding one, `#[admin_initialize(admin_config = config)]`. - **Everything retargets.** Gates read the slot at the declared offset from your account, `admin_transfer` and `admin_renounce` splice only the 32 byte window and leave your neighboring fields untouched, and the IDL shows your account wherever the dedicated PDA used to appear. - **The offset is never in a transaction.** It compiles into the program. The IDL carries no offset argument, and writing `admin_config = ...` or `offset = ...` on the marker or on a gate by hand is a compile error. - **One account fewer** on every gated transaction, the slot travels with state you were already passing. diff --git a/docs/lez/extensions/build-a-spel-extension-library.md b/docs/lez/extensions/build-a-spel-extension-library.md index 85d80eb4..b38aae3a 100644 --- a/docs/lez/extensions/build-a-spel-extension-library.md +++ b/docs/lez/extensions/build-a-spel-extension-library.md @@ -255,7 +255,7 @@ anchor_attr = "my_extension_init" anchor_role = "my_state" ``` -A declared anchor changes how consumers adopt embedded mode. A consumer fn carrying the anchor attribute puts your extension in embedded mode, its `#[account(init)]` param is the embedding account, and the marker stays bare. Writing the role kwarg on the marker becomes a hard error, the anchor fn is the single declaration. When the anchored fn creates several accounts, the consumer names the embedding one with the same kwarg on the anchor (`#[my_extension_init(my_state = config)]`). The anchor is also the coverage gate. A struct carrying your slot marker with no anchored fn refuses to build, because the account would ship born renounced. An anchored fn with no marked field refuses too, the derivation has no carrier. Neither declaration is plain dedicated mode. +A declared anchor changes how consumers adopt embedded mode. A consumer fn carrying the anchor attribute puts your extension in embedded mode, its `#[account(init)]` param is the embedding account, and the marker stays bare. Consumers usually write `#[initialize]` instead of your attribute: it is the framework's shorthand for every activated extension's anchor, expanded into the real attrs in marker order, so one word covers your extension and its neighbors. Nothing to implement on your side, document both spellings. Writing the role kwarg on the marker becomes a hard error, the anchor fn is the single declaration. The shorthand covers only the single-init-param shape. When the anchored fn creates several accounts, the consumer writes your explicit attr and names the embedding one with the same kwarg (`#[my_extension_init(my_state = config)]`). The anchor is also the coverage gate. A struct carrying your slot marker with no anchored fn refuses to build, because the account would ship born renounced. An anchored fn with no marked field refuses too, the derivation has no carrier. Neither declaration is plain dedicated mode. Implement the attribute as a proc macro that injects your `bootstrap_at` call into the handler body, and declare it as an inject wrapper in metadata so your role parameters synthesize on the marked instruction like they do on gates. Reject instructions whose embedding account is not `init`, a bootstrap against an existing account is a takeover. diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index db1448f6..8e1af031 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -267,8 +267,7 @@ mod my_program { use admin_authority::admin_initialize; use freeze_authority::freeze_initialize; - #[admin_initialize] - #[freeze_initialize] + #[initialize] #[instruction] pub fn initialize( #[account(init, pda = literal("program_config"))] mut config: AccountWithMetadata, @@ -283,7 +282,7 @@ mod my_program { What changes: -- **No `freeze_initialize` instruction, an anchor attribute instead.** Mark your account-creating instruction with `#[freeze_initialize]`. The attribute expands to nothing, it declares embedded mode so the module marker stays bare, and the freeze slot is born vacant: it rejects every holder-path caller until the admin appoints the first holder via `freeze_authority_transfer`, the same path that repopulates a renounced slot. There is no initialization ordering to get right because nothing initializes. The admin slot next door is bootstrapped by marking that same instruction with `#[admin_initialize]`, the caller becomes admin in the transaction that creates the account. Two anchors on one instruction compose. +- **No `freeze_initialize` instruction, an anchor attribute instead.** Mark your account-creating instruction with `#[initialize]`, the shorthand for every activated extension's anchor. Here it stands for both `#[admin_initialize]` and `#[freeze_initialize]`, expanded in marker order, and the explicit attrs keep working as the fallback. Freeze's anchor expands to nothing, it declares embedded mode so the module marker stays bare, and the freeze slot is born vacant: it rejects every holder-path caller until the admin appoints the first holder via `freeze_authority_transfer`, the same path that repopulates a renounced slot. There is no initialization ordering to get right because nothing initializes. Admin's anchor bootstraps its slot next door, the caller becomes admin in the transaction that creates the account. - **Slot markers declare the offsets.** `#[admin_slot]` and `#[freeze_slot]` each derive an offset const and a layout test, and the framework reads each slot through its const. A field added above a slot moves the derived offset with it. There is no offset kwarg, the marked field is the only source. - **The declarations must agree.** A struct carrying `#[freeze_slot]` with no instruction carrying `#[freeze_initialize]` refuses to build, the marked field declares embedded mode with nothing anchoring it. An anchored instruction with no marked field refuses too. Neither declaration is plain dedicated mode. - **One account per transaction.** When admin and freeze share the embedding account, management instructions that read both carry the shared account once. `freeze_authority_renounce` drops from 3 accounts to 2.