From 00fc37d1e7ee7e78c77372e78b5a0e9421b89811 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Mon, 29 Jun 2026 15:10:45 +0200 Subject: [PATCH 01/11] docs(lez): add freeze-authority extension guide End-to-end usage guide for the freeze-authority extension: auto vs manual mode, seven management instructions, lifecycle (including recoverable renounce), per-account freeze, PDA-as-authority via CPI, F3 carve-outs. Extends build-a-spel-extension-library.md with three sections motivated by shipping a second extension: attribute-order convention for libs that re-export the #[instruction] shim, the optional wrap_instructions metadata for module-wide gate application, and the hard-dep composition pattern (freeze on top of admin). SUMMARY.md picks up the new page. --- docs/lez/SUMMARY.md | 1 + .../build-a-spel-extension-library.md | 52 ++++ docs/lez/extensions/freeze-authority.md | 264 ++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 docs/lez/extensions/freeze-authority.md diff --git a/docs/lez/SUMMARY.md b/docs/lez/SUMMARY.md index aab5f136..78930537 100644 --- a/docs/lez/SUMMARY.md +++ b/docs/lez/SUMMARY.md @@ -25,6 +25,7 @@ ## Extensions - [Gate program instructions with admin-authority](extensions/admin-authority.md) +- [Freeze program execution with freeze-authority](extensions/freeze-authority.md) - [Build a SPEL extension library](extensions/build-a-spel-extension-library.md) diff --git a/docs/lez/extensions/build-a-spel-extension-library.md b/docs/lez/extensions/build-a-spel-extension-library.md index 1a5da8e2..9df0f872 100644 --- a/docs/lez/extensions/build-a-spel-extension-library.md +++ b/docs/lez/extensions/build-a-spel-extension-library.md @@ -225,6 +225,58 @@ default = 0 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. +### Attribute-order convention in library source + +When a per-instruction gate attribute does shape validation on params (the way `#[require_admin]` checks for an `#[account(pda = literal("admin_config"))]` param and an `#[account(signer)]` param), the order of attributes on the library's own `#[instruction]` fns matters: + +```rust +#[require_my_gate] // runs first — sees params with #[account(...)] intact +#[instruction] // shim runs second — strips #[account(...)] for rustc +pub fn gated_op(/* ... */) -> SpelResult { /* ... */ } +``` + +Rust expands attribute macros top-down. The library's `#[instruction]` shim strips `#[account(...)]` from params. If `#[require_my_gate]` is placed below `#[instruction]`, it runs after the strip and its shape check sees no PDA / no signer params, emitting a confusing error. + +The rule only applies inside libraries that re-export the shim (like the one shown in this guide). Consumer code uses SPEL's no-op `#[instruction]` from the prelude, which doesn't strip anything; order doesn't matter there. + +## Auto-wrapping every consumer instruction (optional) + +If your extension is a circuit-breaker primitive (an emergency stop, a re-entrancy guard, a global rate limit), you may want EVERY consumer instruction gated, not just the ones the consumer remembered to annotate. The framework supports this via a `wrap_instructions` metadata field that activates a module-level wrap hook. + +Add this section to your `my-extension/Cargo.toml`: + +```toml +[package.metadata.spel.wrap_instructions] +wrapper = "my_extension::require_my_gate" +skip = "manual" +self_exempt_marker = "my_extension_exempt" +exempt = [ + "admin_authority::admin_initialize", + "admin_authority::admin_transfer", + "admin_authority::admin_renounce", +] +``` + +- `wrapper`: a qualified path to the per-instruction attribute the framework prepends onto each non-exempt dispatched fn. Reuses the same `#[require_my_gate]` attribute consumers apply by hand in manual mode — one proc-macro, two callers. +- `skip`: the arg literal on your `#[my_extension]` marker that DISABLES auto-wrap. With `skip = "manual"`, `#[my_extension(manual)]` opts out of the wrap and `#[my_extension]` (bare) opts in. +- `self_exempt_marker`: an attribute name the framework recognizes as "skip this fn from wrap". Add another pass-through proc-macro of that name to your macros crate; consumers carry it on any instruction they want to remain callable while gated. +- `exempt`: a list of cross-crate dispatched instructions to skip unconditionally. Use this when composing with another extension whose ops must stay operable even when your wrap is active. (Self-exemptions for your own instructions go on the fn via `self_exempt_marker` instead.) + +When the consumer puts `#[my_extension]` on their `#[lez_program]` mod, the framework hook walks the dispatcher table and prepends `#[require_my_gate]` to every fn that isn't in `exempt` and doesn't carry `#[my_extension_exempt]`. The wrap is invisible to consumers — they write normal code; the gate appears as if by magic. + +The hook is opt-in: omit `wrap_instructions` from your metadata and the framework leaves all instructions alone. This is the right choice for most extensions (pure data primitives, single-instruction gates, etc.). + +## Composing with another extension (hard dep) + +Some extensions naturally build on others. `freeze-authority` depends on `admin-authority` — its freeze-authority slot is governed by admin signatures. When your extension does this: + +1. **Declare a normal Cargo path dep** on the other extension in your `Cargo.toml`. Consumers get both extensions in their dep graph automatically. +2. **Add both markers to the consumer's mod.** Consumers write `#[admin_authority] #[my_extension]` on their `#[lez_program]` mod. Each marker triggers its own discovery. +3. **Import the gate attributes you compose with.** E.g. `use admin_authority::require_admin;` in your library source, then `#[require_admin]` on instructions that should require admin sig (like an initialization that creates your config PDA). +4. **List the other extension's exempt-while-wrapped instructions** in your `wrap_instructions.exempt` if applicable. freeze-authority lists admin-authority's three management instructions so they stay callable while the program is frozen. + +The framework deduplicates path-dep dirs, so admin-authority is scanned once even if both your extension and the consumer name it as a path dep. + ## Consumer integration A consumer adds your extension to their `Cargo.toml`: diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md new file mode 100644 index 00000000..0bfcce7f --- /dev/null +++ b/docs/lez/extensions/freeze-authority.md @@ -0,0 +1,264 @@ +# Freeze program execution with freeze-authority + +{% hint style="warning" %} +## Important + +This page is an early draft and may be incomplete or incorrect. Expect changes, missing prerequisites, and commands that might not work in your setup. We are actively working to complete and verify this content. +{% endhint %} + +`freeze-authority` is a SPEL extension that adds an emergency-stop primitive to your LEZ program. A designated freeze authority can pause all program execution (program-wide freeze) and block specific accounts from interacting (per-account freeze). The role can be transferred by the admin or renounced; while the program is frozen, only unfreeze, authority management, and admin operations remain callable. This page walks through using `freeze-authority` from an app developer's perspective. If you are building a different extension, see [Build a SPEL extension library](build-a-spel-extension-library.md) instead. + +`freeze-authority` depends on `admin-authority`. The admin governs the freeze authority slot; the freeze authority governs the frozen flags. See [Gate program instructions with admin-authority](admin-authority.md) for the admin layer. + +## When to use it + +Pick `freeze-authority` when your program needs: + +- An emergency circuit breaker for incident response (`freeze` everything until the team can investigate). +- A blocklist for sanctioned or compromised accounts (`block` specific `AccountId`s while the rest of the program keeps running). +- Both layered — global pause plus per-account blocks for graduated response. + +If your program needs a permanent pause with no recovery, use `admin_renounce` after deployment instead — freeze-authority is the wrong primitive for one-way upgrades. + +## Add the dependency + +In your program's `Cargo.toml`: + +```toml +[dependencies] +admin-authority = { git = "https://github.com/mmlado/spel-admin-authority" } +freeze-authority = { git = "https://github.com/mmlado/spel-freeze-authority" } +spel-framework = { git = "https://github.com/logos-co/spel" } +``` + +The `admin-authority` dependency is required because freeze-authority composes with it. The `freeze-authority-macros` sub-crate is pulled in transitively. + +## Annotate the module + +`freeze-authority` ships two modes: **auto** (default, F3-strict) and **manual** (explicit opt-in per instruction). Both require the admin marker so the freeze authority slot has an owner. + +### Auto mode (recommended default) + +Every dispatched instruction except the F3 carve-outs and admin operations is automatically gated by the freeze check. Consumers opt OUT per instruction with `#[freeze_exempt]`. + +```rust +use spel_framework::prelude::*; +use admin_authority::admin_authority; +use freeze_authority::{freeze_authority, freeze_exempt}; + +#[lez_program] +#[admin_authority] +#[freeze_authority] +mod my_program { + use super::*; + + #[instruction] + pub fn transfer(/* ... */) -> SpelResult { /* ... */ } // auto-gated + + #[instruction] + #[freeze_exempt] + pub fn balance_of(/* ... */) -> SpelResult { /* ... */ } // exempt — callable while frozen +} +``` + +### Manual mode + +Auto-wrap is disabled; the consumer applies `#[require_not_frozen]` only to instructions they want gated. F3 conformance becomes the consumer's responsibility. + +```rust +use spel_framework::prelude::*; +use admin_authority::admin_authority; +use freeze_authority::{freeze_authority, require_not_frozen}; + +#[lez_program] +#[admin_authority] +#[freeze_authority(manual)] +mod my_program { + use super::*; + + #[instruction] + #[require_not_frozen] + pub fn transfer(/* ... */) -> SpelResult { /* ... */ } // explicitly gated + + #[instruction] + pub fn balance_of(/* ... */) -> SpelResult { /* ... */ } // NOT gated +} +``` + +That single annotation pair (plus `#[admin_authority]`) exposes seven new instructions in your program's IDL: + +| Instruction | Purpose | +|---|---| +| `freeze_initialize` | Creates the freeze Config PDA and sets the first freeze authority. Requires admin signature. Must be called once after deployment. | +| `freeze_program` | Sets the program-wide frozen flag to true. Freeze authority only. | +| `freeze_program_release` | Sets the program-wide frozen flag to false. Freeze authority only. Callable while frozen. | +| `freeze_authority_transfer` | Replaces the current freeze authority with a new signer or PDA. Admin only. Callable while frozen. | +| `freeze_authority_renounce` | Vacates the freeze authority slot. Admin OR freeze authority self. Callable while frozen. Recoverable by admin via transfer. | +| `freeze_account(target)` | Sets per-account frozen flag to true for `target`. Freeze authority only. Callable while frozen. | +| `freeze_account_release(target)` | Sets per-account frozen flag to false for `target`. Freeze authority only. Callable while frozen. | + +{% hint style="warning" %} +## Initialization window + +Until `freeze_initialize` is called, the freeze Config PDA does not exist and no gates are active. Unlike `admin_initialize`, freeze initialization is NOT front-runnable — `freeze_initialize` requires the admin's signature. But it does require `admin_initialize` to have run first. Recommended pattern: bundle `admin_initialize` and `freeze_initialize` in the same transaction immediately after deployment. +{% endhint %} + +## Gate an instruction + +In auto mode, all instructions are gated by default — you don't add any annotation. In manual mode, add `#[require_not_frozen]` to instructions you want gated: + +```rust +#[instruction] +#[require_not_frozen] +pub fn transfer( + #[account(mut, pda = literal("balance"))] mut balance: AccountWithMetadata, + #[account(signer)] caller: AccountWithMetadata, + amount: u64, +) -> SpelResult { + /* your logic */ +} +``` + +The injected gate performs two checks before the handler body runs: + +1. **Program-wide check** — reads `freeze_config.is_frozen`. Rejects if true. +2. **Per-account check** — derives the PDA at `(program_id, "frozen", caller.account_id)` and reads `is_frozen`. Rejects if true. Missing PDA = not frozen. + +Both checks pass for the call to proceed. + +### Exempt an instruction from auto mode + +Use `#[freeze_exempt]` to opt out per instruction: + +```rust +#[instruction] +#[freeze_exempt] +pub fn balance_of(/* ... */) -> SpelResult { /* read-only, safe while frozen */ } +``` + +The framework reads `self_exempt_marker = "freeze_exempt"` from freeze-authority's Cargo metadata and skips the wrap for any fn carrying the attribute. + +## Choose an initial freeze authority + +`freeze_initialize` takes a `FreezeCandidate` — same shape as `AdminCandidate` from admin-authority: + +```rust +pub enum FreezeCandidate { + /// The new freeze authority is a keyholder. Validated by checking that + /// `new_freeze_account.is_authorized == true` (co-signed the tx). + Signer, + /// The new freeze authority is a program-owned PDA. Validated by deriving + /// the address from (program_id, seed) and confirming the PDA exists on chain. + Pda { program_id: AccountId, seed: [u8; 32] }, +} +``` + +From the SPEL CLI (note the required admin signature): + +```bash +spel tx send freeze_initialize \ + --admin-signer \ + --new-freeze Signer \ + --new-freeze-account +``` + +## Freeze and unfreeze the program + +```bash +# Freeze: rejects every interaction except the F3 carve-outs and admin operations. +spel tx send freeze_program + +# Unfreeze: restores normal operation. +spel tx send freeze_program_release +``` + +Both require the current freeze authority to sign. + +## Freeze and unfreeze a specific account + +```bash +# Block account X from interacting with this program. +spel tx send freeze_account --target + +# Restore X's access. +spel tx send freeze_account_release --target +``` + +When account X is frozen, any instruction in your program that's auto-gated or carries `#[require_not_frozen]` rejects when X is the signer. Other accounts are unaffected. Per-account state survives the program-wide frozen flag toggling — the two layers are independent. + +## Transfer freeze authority to another party + +`freeze_authority_transfer` requires the admin to sign. The freeze authority slot can also be transferred from a Renounced (vacant) state, so admins can rotate the role with or without an interim vacancy: + +```bash +spel tx send freeze_authority_transfer \ + --admin-signer \ + --new-freeze Signer \ + --new-freeze-account +``` + +## Use a program (PDA) as freeze authority + +To delegate freeze authority to another program (e.g. a multisig or a circuit-breaker DAO), pass `FreezeCandidate::Pda`: + +```bash +spel tx send freeze_authority_transfer \ + --admin-signer \ + --new-freeze 'Pda { program_id: , seed: <32-byte-seed> }' \ + --new-freeze-account +``` + +When the multisig invokes `freeze_program` (or any freeze-authority-signed instruction), it does so through a chained call and declares its PDA in `caller-pda-seeds`. LEZ verifies the seed and propagates `is_authorized = true`; the gate sees the PDA as the legitimate freeze authority. + +## Renounce freeze authority + +```bash +# Either the current admin OR the current freeze authority can sign. +spel tx send freeze_authority_renounce +``` + +Unlike admin renounce, this is NOT terminal. The freeze authority slot becomes vacant; the admin can repopulate it later via `freeze_authority_transfer`. While the slot is vacant, `freeze_program`, `freeze_program_release`, `freeze_account`, and `freeze_account_release` all fail. The program-wide `is_frozen` flag and per-account states are preserved at the moment of renounce — they don't reset. + +If the admin has already been renounced first (terminal), the freeze slot becomes effectively permanent: there is no one to call `freeze_authority_transfer` to repopulate it. Plan the order of renounces carefully if you intend to commit to no-future-freeze. + +## Verify your integration + +After building your program, check that the freeze instructions appear in the IDL: + +```bash +spel generate-idl path/to/your/program/src/main.rs | jq '.instructions[].name' +``` + +Expected output includes: + +``` +"admin_initialize" +"admin_transfer" +"admin_renounce" +"freeze_initialize" +"freeze_program" +"freeze_program_release" +"freeze_authority_transfer" +"freeze_authority_renounce" +"freeze_account" +"freeze_account_release" +``` + +Plus your own instructions. If the freeze instructions are missing, the most common causes are: + +- `freeze-authority` not declared as a path or git dependency in your `Cargo.toml`. +- `admin-authority` missing (freeze-authority hard-depends on it). +- `#[freeze_authority]` placed outside `#[lez_program]` rather than inside. +- Cached macro expansion, run `cargo clean -p ` and rebuild. + +## Security notes + +- **Initialization order matters.** `freeze_initialize` requires admin signature and an initialized `admin_config`. Bundle both inits in the same transaction immediately after deployment. +- **Renounce is recoverable (unlike admin).** Vacating the freeze authority slot is reversible by the admin via `freeze_authority_transfer`. Plan accordingly if your operational model assumes the role is permanent — only renouncing admin first locks it down. +- **Exempt is shallow.** A `#[freeze_exempt]` consumer fn that uses `chained_call` to invoke a gated fn still hits the gated fn's check. Frozen-state behavior of chained calls is determined by the called fn's exemption status, not the caller's. +- **Auto mode covers all dispatched instructions.** Including admin operations? No — admin-authority's three management instructions are exempt by an explicit entry in freeze-authority's metadata. Admin can still transfer or renounce while the program is frozen. This is by design to avoid deadlock from a lost admin key during freeze. +- **Per-account PDAs persist.** Per-account freeze state writes a PDA per target. Once initialized, the PDA exists for the program's lifetime (LEZ has no close primitive). Toggling release writes `is_frozen = false`; the PDA itself stays. No rent applies in LEZ. + +## Reference + +Source: [github.com/mmlado/spel-freeze-authority](https://github.com/mmlado/spel-freeze-authority). The companion repository contains the authority lifecycle state diagram, ADRs for design decisions (including ADR-0007 on renounce semantics and ADR-0008 on per-account encoding), the LEZ rent investigation, and reference sample programs demonstrating both auto and manual modes end-to-end. From 42e1d63dd8e8c7702a7aaf5fa388bc6b0f99b5ca Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Thu, 30 Jul 2026 20:03:17 +0200 Subject: [PATCH 02/11] docs(extensions): freeze guide matches the shipped surface freeze_initialize self-elects the admin and takes no candidate, every CLI block uses the real invocation form, the candidate enum moves to the transfer section where it applies, and the embedded mode section covers the shared-account layout, born-vacant slots, and splice-only writes. --- docs/lez/extensions/freeze-authority.md | 102 ++++++++++++++++-------- 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index 0bfcce7f..f0f2bfd3 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -138,38 +138,25 @@ pub fn balance_of(/* ... */) -> SpelResult { /* read-only, safe while frozen */ The framework reads `self_exempt_marker = "freeze_exempt"` from freeze-authority's Cargo metadata and skips the wrap for any fn carrying the attribute. -## Choose an initial freeze authority +## Initialize the freeze authority -`freeze_initialize` takes a `FreezeCandidate` — same shape as `AdminCandidate` from admin-authority: - -```rust -pub enum FreezeCandidate { - /// The new freeze authority is a keyholder. Validated by checking that - /// `new_freeze_account.is_authorized == true` (co-signed the tx). - Signer, - /// The new freeze authority is a program-owned PDA. Validated by deriving - /// the address from (program_id, seed) and confirming the PDA exists on chain. - Pda { program_id: AccountId, seed: [u8; 32] }, -} -``` - -From the SPEL CLI (note the required admin signature): +`freeze_initialize` takes no candidate argument. The admin signs, and the admin becomes the initial freeze authority, the same self-election pattern as `admin_initialize`. Hand the role to a dedicated operations key or a PDA afterwards with `freeze_authority_transfer`. ```bash -spel tx send freeze_initialize \ - --admin-signer \ - --new-freeze Signer \ - --new-freeze-account +spel --idl program-idl.json --program -- \ + freeze-initialize --caller ``` ## Freeze and unfreeze the program ```bash # Freeze: rejects every interaction except the F3 carve-outs and admin operations. -spel tx send freeze_program +spel --idl program-idl.json --program -- \ + freeze-program --caller # Unfreeze: restores normal operation. -spel tx send freeze_program_release +spel --idl program-idl.json --program -- \ + freeze-program-release --caller ``` Both require the current freeze authority to sign. @@ -178,23 +165,39 @@ Both require the current freeze authority to sign. ```bash # Block account X from interacting with this program. -spel tx send freeze_account --target +spel --idl program-idl.json --program -- \ + freeze-account --caller --target # Restore X's access. -spel tx send freeze_account_release --target +spel --idl program-idl.json --program -- \ + freeze-account-release --caller --target ``` When account X is frozen, any instruction in your program that's auto-gated or carries `#[require_not_frozen]` rejects when X is the signer. Other accounts are unaffected. Per-account state survives the program-wide frozen flag toggling — the two layers are independent. ## Transfer freeze authority to another party -`freeze_authority_transfer` requires the admin to sign. The freeze authority slot can also be transferred from a Renounced (vacant) state, so admins can rotate the role with or without an interim vacancy: +`freeze_authority_transfer` requires the admin to sign. It takes a `FreezeCandidate` describing the new holder, the same shape as `AdminCandidate`, paired with a `new_account` that carries the chain-state evidence: + +```rust +pub enum FreezeCandidate { + /// The new freeze authority is a keyholder. Validated by checking that + /// the new account co-signed the transaction. + Signer, + /// The new freeze authority is a program-owned PDA. Validated by deriving + /// the address from (program_id, seed) and confirming the PDA exists on chain. + Pda { program_id: AccountId, seed: [u8; 32] }, +} +``` + +The slot can also be transferred from a Renounced (vacant) state, so admins can rotate the role with or without an interim vacancy: ```bash -spel tx send freeze_authority_transfer \ - --admin-signer \ - --new-freeze Signer \ - --new-freeze-account +spel --idl program-idl.json --program -- \ + freeze-authority-transfer \ + --caller \ + --new-account \ + --candidate Signer ``` ## Use a program (PDA) as freeze authority @@ -202,10 +205,11 @@ spel tx send freeze_authority_transfer \ To delegate freeze authority to another program (e.g. a multisig or a circuit-breaker DAO), pass `FreezeCandidate::Pda`: ```bash -spel tx send freeze_authority_transfer \ - --admin-signer \ - --new-freeze 'Pda { program_id: , seed: <32-byte-seed> }' \ - --new-freeze-account +spel --idl program-idl.json --program -- \ + freeze-authority-transfer \ + --caller \ + --new-account \ + --candidate '{"Pda": {"program_id": "", "seed": "<32-byte-hex-seed>"}}' ``` When the multisig invokes `freeze_program` (or any freeze-authority-signed instruction), it does so through a chained call and declares its PDA in `caller-pda-seeds`. LEZ verifies the seed and propagates `is_authorized = true`; the gate sees the PDA as the legitimate freeze authority. @@ -214,13 +218,43 @@ When the multisig invokes `freeze_program` (or any freeze-authority-signed instr ```bash # Either the current admin OR the current freeze authority can sign. -spel tx send freeze_authority_renounce +spel --idl program-idl.json --program -- \ + freeze-authority-renounce --caller ``` Unlike admin renounce, this is NOT terminal. The freeze authority slot becomes vacant; the admin can repopulate it later via `freeze_authority_transfer`. While the slot is vacant, `freeze_program`, `freeze_program_release`, `freeze_account`, and `freeze_account_release` all fail. The program-wide `is_frozen` flag and per-account states are preserved at the moment of renounce — they don't reset. If the admin has already been renounced first (terminal), the freeze slot becomes effectively permanent: there is no one to call `freeze_authority_transfer` to repopulate it. Plan the order of renounces carefully if you intend to commit to no-future-freeze. +## Embedded mode, the freeze slot inside your own account + +Like admin-authority, the freeze state can live inside one of your program's own accounts instead of a dedicated Config PDA, and both extensions can share the same account at distinct offsets: + +```rust +#[account_type] +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug)] +pub struct ProgramConfig { + pub value: u64, // bytes 0..8 + pub padding: [u8; 24], // bytes 8..32 + pub admin: AdminConfig, // bytes 32..64 + pub freeze: FreezeConfig, // bytes 64..97 +} + +#[lez_program] +#[admin_authority(admin_config = config, offset = 32)] +#[freeze_authority(freeze_config = config, offset = 64)] +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. +- **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. + +The library repository ships `freeze-authority-sample-embedded` with the full layout, adjacent-window tests, and a committed dry-run walkthrough. + ## Verify your integration After building your program, check that the freeze instructions appear in the IDL: @@ -244,7 +278,7 @@ Expected output includes: "freeze_account_release" ``` -Plus your own instructions. If the freeze instructions are missing, the most common causes are: +Plus your own instructions. In embedded mode neither `admin_initialize` nor `freeze_initialize` appears, and the config accounts in every instruction are your own embedding account instead of the dedicated PDAs. If the freeze instructions are missing, the most common causes are: - `freeze-authority` not declared as a path or git dependency in your `Cargo.toml`. - `admin-authority` missing (freeze-authority hard-depends on it). From f9dca040278455f23a3652f8fbcb01355e8425b6 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Sat, 1 Aug 2026 13:54:17 +0200 Subject: [PATCH 03/11] docs(extensions): admin transfer flags follow the library rename The transfer commands now take --new-account and --candidate. Both CLI examples updated to match the shipped surface. --- docs/lez/extensions/admin-authority.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/lez/extensions/admin-authority.md b/docs/lez/extensions/admin-authority.md index 3036ba97..e8bdf881 100644 --- a/docs/lez/extensions/admin-authority.md +++ b/docs/lez/extensions/admin-authority.md @@ -130,8 +130,8 @@ pub enum AdminCandidate { spel --idl program-idl.json --program -- \ admin-transfer \ --caller \ - --new-admin-account \ - --new-admin Signer + --new-account \ + --candidate Signer ``` A `Signer` transfer needs the new admin's signature on the same transaction, which proves the keyholder consents. That means two parties sign one message, an off-chain co-signing exchange handled by the CLI's witness exchange flow. @@ -146,8 +146,8 @@ To delegate admin authority to another program, for example a multisig, use `Adm spel --idl program-idl.json --program -- \ admin-transfer \ --caller \ - --new-admin-account \ - --new-admin '{"Pda": {"program_id": "", "seed": "<32-byte-hex-seed>"}}' + --new-account \ + --candidate '{"Pda": {"program_id": "", "seed": "<32-byte-hex-seed>"}}' ``` The PDA must already be deployed, an undeployed candidate is rejected. When the multisig later wants to invoke a gated instruction on your program, it does so through a chained call and declares its admin PDA in `caller-pda-seeds`. LEZ verifies the seed and propagates `is_authorized = true` to your program; the `#[require_admin]` check then sees the PDA as the legitimate admin. No private key is needed for the PDA, authorization comes from the seed delegation. From 0cdaf67a12f328f99141c3e34ba83af09146f490 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Tue, 4 Aug 2026 06:52:16 +0200 Subject: [PATCH 04/11] docs: embedded example gains the slot markers The struct shows #[admin_slot] and #[freeze_slot] and the notes cover the #[admin_initialize] bootstrap next door and the layout agreement check the markers add. --- docs/lez/extensions/freeze-authority.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index f0f2bfd3..1a374b26 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -236,19 +236,35 @@ Like admin-authority, the freeze state can live inside one of your program's own pub struct ProgramConfig { pub value: u64, // bytes 0..8 pub padding: [u8; 24], // bytes 8..32 + #[admin_slot] pub admin: AdminConfig, // bytes 32..64 + #[freeze_slot] pub freeze: FreezeConfig, // bytes 64..97 } #[lez_program] #[admin_authority(admin_config = config, offset = 32)] #[freeze_authority(freeze_config = config, offset = 64)] -mod my_program { /* ... */ } +mod my_program { + use admin_authority::admin_initialize; + + #[admin_initialize] + #[instruction] + pub fn initialize( + #[account(init, pda = literal("program_config"))] mut config: AccountWithMetadata, + #[account(signer)] signer: AccountWithMetadata, + ) -> SpelResult { + // write the struct with both slots defaulted; the admin + // bootstrap is injected by the attribute + // ... + } +} ``` 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. +- **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. - **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. From d81f52aa3096776c3fc901f2445da78a4e2f6694 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Tue, 4 Aug 2026 09:19:29 +0200 Subject: [PATCH 05/11] docs(extensions): adapt the freeze page to docusaurus Frontmatter and admonitions, matching the admin and library pages. --- docs/lez/extensions/freeze-authority.md | 27 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index 1a374b26..4b38c9c6 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -1,10 +1,21 @@ -# Freeze program execution with freeze-authority +--- +title: Freeze program execution with freeze-authority +doc_type: procedure +product: lez +topics: lez +steps_layout: sectioned +authors: mmlado +owner: logos +doc_version: 1 +slug: freeze-authority +sidebar_position: 3 +--- -{% hint style="warning" %} -## Important +# Freeze program execution with freeze-authority +:::warning This page is an early draft and may be incomplete or incorrect. Expect changes, missing prerequisites, and commands that might not work in your setup. We are actively working to complete and verify this content. -{% endhint %} +::: `freeze-authority` is a SPEL extension that adds an emergency-stop primitive to your LEZ program. A designated freeze authority can pause all program execution (program-wide freeze) and block specific accounts from interacting (per-account freeze). The role can be transferred by the admin or renounced; while the program is frozen, only unfreeze, authority management, and admin operations remain callable. This page walks through using `freeze-authority` from an app developer's perspective. If you are building a different extension, see [Build a SPEL extension library](build-a-spel-extension-library.md) instead. @@ -97,11 +108,9 @@ That single annotation pair (plus `#[admin_authority]`) exposes seven new instru | `freeze_account(target)` | Sets per-account frozen flag to true for `target`. Freeze authority only. Callable while frozen. | | `freeze_account_release(target)` | Sets per-account frozen flag to false for `target`. Freeze authority only. Callable while frozen. | -{% hint style="warning" %} -## Initialization window - -Until `freeze_initialize` is called, the freeze Config PDA does not exist and no gates are active. Unlike `admin_initialize`, freeze initialization is NOT front-runnable — `freeze_initialize` requires the admin's signature. But it does require `admin_initialize` to have run first. Recommended pattern: bundle `admin_initialize` and `freeze_initialize` in the same transaction immediately after deployment. -{% endhint %} +:::warning +**Initialization window.** Until `freeze_initialize` is called, the freeze Config PDA does not exist and no gates are active. Unlike `admin_initialize`, freeze initialization is NOT front-runnable — `freeze_initialize` requires the admin's signature. But it does require `admin_initialize` to have run first. Recommended pattern: bundle `admin_initialize` and `freeze_initialize` in the same transaction immediately after deployment. +::: ## Gate an instruction From 1775858000e25a1e26f6de02ca721ed3f704680b Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Tue, 4 Aug 2026 09:32:08 +0200 Subject: [PATCH 06/11] docs(extensions): consolidate the duplicated auto-wrap section The admin branch's M2.5 alignment and the freeze branch each carried an auto-wrap section, and the merge kept both. One section survives at the earlier position with the richer body, the activation walk-through, and the composition note. --- .../build-a-spel-extension-library.md | 50 +++++++------------ 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/docs/lez/extensions/build-a-spel-extension-library.md b/docs/lez/extensions/build-a-spel-extension-library.md index 8cb665dc..1a1929ef 100644 --- a/docs/lez/extensions/build-a-spel-extension-library.md +++ b/docs/lez/extensions/build-a-spel-extension-library.md @@ -196,17 +196,28 @@ Any consumer instruction carrying `#[require_my_gate]` gets the listed parameter ## Auto-wrap every instruction (optional) -A gate that should apply to every dispatched instruction by default, the freeze pattern, declares a wrap block instead of relying on consumers to annotate each function: +If your extension is a circuit-breaker primitive (an emergency stop, a re-entrancy guard, a global rate limit), you may want EVERY consumer instruction gated, not just the ones the consumer remembered to annotate. The framework supports this via a `wrap_instructions` metadata field that activates a module-level wrap hook: ```toml [package.metadata.spel.wrap_instructions] wrapper = "my_extension::require_my_gate" skip = "manual" -self_exempt_marker = "my_gate_exempt" -exempt = ["admin_authority::admin_transfer"] +self_exempt_marker = "my_extension_exempt" +exempt = [ + "admin_authority::admin_initialize", + "admin_authority::admin_transfer", + "admin_authority::admin_renounce", +] ``` -The framework prepends the wrapper attribute to every instruction the consumer dispatches, including other extensions' discovered instructions. `skip` names a marker word that disables auto mode (`#[my_extension(manual)]`), `self_exempt_marker` is the attribute your own library and consumers use to opt single instructions out, and `exempt` carves out other extensions' instructions by qualified name. Injection and wrapping compose, a wrapped instruction gets your gate's parameters injected like an annotated one. +- `wrapper`: a qualified path to the per-instruction attribute the framework prepends onto each non-exempt dispatched function, including other extensions' discovered instructions. Reuses the same `#[require_my_gate]` attribute consumers apply by hand in manual mode — one proc-macro, two callers. +- `skip`: the arg literal on your `#[my_extension]` marker that DISABLES auto-wrap. With `skip = "manual"`, `#[my_extension(manual)]` opts out of the wrap and `#[my_extension]` (bare) opts in. +- `self_exempt_marker`: an attribute name the framework recognises as "skip this function from wrap". Add another pass-through proc-macro of that name to your macros crate; consumers carry it on any instruction they want to remain callable while gated. +- `exempt`: a list of cross-crate dispatched instructions to skip unconditionally. Use this when composing with another extension whose ops must stay operable even when your wrap is active. (Self-exemptions for your own instructions go on the function via `self_exempt_marker` instead.) + +When the consumer puts `#[my_extension]` on their `#[lez_program]` mod, the framework walks the dispatcher table and prepends `#[require_my_gate]` to every function that is not in `exempt` and does not carry `#[my_extension_exempt]`. Consumers write normal code, the gate arrives with the wrap. Injection and wrapping compose: a wrapped instruction gets your gate's parameters injected like an annotated one. + +The hook is opt-in, omit `wrap_instructions` from your metadata and the framework leaves all instructions alone. This is the right choice for most extensions (pure data primitives, single-instruction gates, etc.). ## Embedded mode (optional) @@ -238,7 +249,7 @@ When two extensions embed into the same consumer account at distinct offsets, th ### Attribute-order convention in library source -When a per-instruction gate attribute does shape validation on params (the way `#[require_admin]` checks for an `#[account(pda = literal("admin_config"))]` param and an `#[account(signer)]` param), the order of attributes on the library's own `#[instruction]` fns matters: +When a per-instruction gate attribute does shape validation on parameters (the way `#[require_admin]` checks for an `#[account(pda = literal("admin_config"))]` parameter and an `#[account(signer)]` parameter), the order of attributes on the library's own `#[instruction]` functions matters: ```rust #[require_my_gate] // runs first — sees params with #[account(...)] intact @@ -246,37 +257,10 @@ When a per-instruction gate attribute does shape validation on params (the way ` pub fn gated_op(/* ... */) -> SpelResult { /* ... */ } ``` -Rust expands attribute macros top-down. The library's `#[instruction]` shim strips `#[account(...)]` from params. If `#[require_my_gate]` is placed below `#[instruction]`, it runs after the strip and its shape check sees no PDA / no signer params, emitting a confusing error. +Rust expands attribute macros top-down. The library's `#[instruction]` shim strips `#[account(...)]` from parameters. If `#[require_my_gate]` is placed below `#[instruction]`, it runs after the strip, no PDA or signer parameters are left for its shape check, and it emits a confusing error. The rule only applies inside libraries that re-export the shim (like the one shown in this guide). Consumer code uses SPEL's no-op `#[instruction]` from the prelude, which doesn't strip anything; order doesn't matter there. -## Auto-wrapping every consumer instruction (optional) - -If your extension is a circuit-breaker primitive (an emergency stop, a re-entrancy guard, a global rate limit), you may want EVERY consumer instruction gated, not just the ones the consumer remembered to annotate. The framework supports this via a `wrap_instructions` metadata field that activates a module-level wrap hook. - -Add this section to your `my-extension/Cargo.toml`: - -```toml -[package.metadata.spel.wrap_instructions] -wrapper = "my_extension::require_my_gate" -skip = "manual" -self_exempt_marker = "my_extension_exempt" -exempt = [ - "admin_authority::admin_initialize", - "admin_authority::admin_transfer", - "admin_authority::admin_renounce", -] -``` - -- `wrapper`: a qualified path to the per-instruction attribute the framework prepends onto each non-exempt dispatched fn. Reuses the same `#[require_my_gate]` attribute consumers apply by hand in manual mode — one proc-macro, two callers. -- `skip`: the arg literal on your `#[my_extension]` marker that DISABLES auto-wrap. With `skip = "manual"`, `#[my_extension(manual)]` opts out of the wrap and `#[my_extension]` (bare) opts in. -- `self_exempt_marker`: an attribute name the framework recognizes as "skip this fn from wrap". Add another pass-through proc-macro of that name to your macros crate; consumers carry it on any instruction they want to remain callable while gated. -- `exempt`: a list of cross-crate dispatched instructions to skip unconditionally. Use this when composing with another extension whose ops must stay operable even when your wrap is active. (Self-exemptions for your own instructions go on the fn via `self_exempt_marker` instead.) - -When the consumer puts `#[my_extension]` on their `#[lez_program]` mod, the framework hook walks the dispatcher table and prepends `#[require_my_gate]` to every fn that isn't in `exempt` and doesn't carry `#[my_extension_exempt]`. The wrap is invisible to consumers — they write normal code; the gate appears as if by magic. - -The hook is opt-in: omit `wrap_instructions` from your metadata and the framework leaves all instructions alone. This is the right choice for most extensions (pure data primitives, single-instruction gates, etc.). - ## Composing with another extension (hard dep) Some extensions naturally build on others. `freeze-authority` depends on `admin-authority` — its freeze-authority slot is governed by admin signatures. When your extension does this: From ac952fb295f8ee9861469f047cf4fe9b20bd7cc4 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Tue, 4 Aug 2026 10:23:12 +0200 Subject: [PATCH 07/11] docs(extensions): satisfy the style bot in prose British spellings, abbreviations spelled out, and the flagged anthropomorphism reworded on the freeze page. --- docs/lez/extensions/freeze-authority.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index 4b38c9c6..bcd358d2 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -145,7 +145,7 @@ Use `#[freeze_exempt]` to opt out per instruction: pub fn balance_of(/* ... */) -> SpelResult { /* read-only, safe while frozen */ } ``` -The framework reads `self_exempt_marker = "freeze_exempt"` from freeze-authority's Cargo metadata and skips the wrap for any fn carrying the attribute. +The framework reads `self_exempt_marker = "freeze_exempt"` from freeze-authority's Cargo metadata and skips the wrap for any function carrying the attribute. ## Initialize the freeze authority @@ -221,7 +221,7 @@ spel --idl program-idl.json --program -- \ --candidate '{"Pda": {"program_id": "", "seed": "<32-byte-hex-seed>"}}' ``` -When the multisig invokes `freeze_program` (or any freeze-authority-signed instruction), it does so through a chained call and declares its PDA in `caller-pda-seeds`. LEZ verifies the seed and propagates `is_authorized = true`; the gate sees the PDA as the legitimate freeze authority. +When the multisig invokes `freeze_program` (or any freeze-authority-signed instruction), it does so through a chained call and declares its PDA in `caller-pda-seeds`. LEZ verifies the seed and propagates `is_authorized = true`; the gate accepts the PDA as the legitimate freeze authority. ## Renounce freeze authority @@ -314,7 +314,7 @@ Plus your own instructions. In embedded mode neither `admin_initialize` nor `fre - **Initialization order matters.** `freeze_initialize` requires admin signature and an initialized `admin_config`. Bundle both inits in the same transaction immediately after deployment. - **Renounce is recoverable (unlike admin).** Vacating the freeze authority slot is reversible by the admin via `freeze_authority_transfer`. Plan accordingly if your operational model assumes the role is permanent — only renouncing admin first locks it down. -- **Exempt is shallow.** A `#[freeze_exempt]` consumer fn that uses `chained_call` to invoke a gated fn still hits the gated fn's check. Frozen-state behavior of chained calls is determined by the called fn's exemption status, not the caller's. +- **Exempt is shallow.** A `#[freeze_exempt]` consumer function that uses `chained_call` to invoke a gated function still hits the gated function's check. Frozen-state behaviour of chained calls is determined by the called function's exemption status, not the caller's. - **Auto mode covers all dispatched instructions.** Including admin operations? No — admin-authority's three management instructions are exempt by an explicit entry in freeze-authority's metadata. Admin can still transfer or renounce while the program is frozen. This is by design to avoid deadlock from a lost admin key during freeze. - **Per-account PDAs persist.** Per-account freeze state writes a PDA per target. Once initialized, the PDA exists for the program's lifetime (LEZ has no close primitive). Toggling release writes `is_frozen = false`; the PDA itself stays. No rent applies in LEZ. From f4f43e0f692ffac96913b06c2fd4ad65e8aedc68 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Mon, 10 Aug 2026 13:18:55 +0200 Subject: [PATCH 08/11] docs(extensions): fix the candidate type and imports FreezeCandidate joins both import snippets, the generated dispatcher references it. The Pda variant's program_id field is a ProgramId, not an AccountId. --- docs/lez/extensions/freeze-authority.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index bcd358d2..95f115ec 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -55,7 +55,7 @@ Every dispatched instruction except the F3 carve-outs and admin operations is au ```rust use spel_framework::prelude::*; use admin_authority::admin_authority; -use freeze_authority::{freeze_authority, freeze_exempt}; +use freeze_authority::{freeze_authority, freeze_exempt, FreezeCandidate}; #[lez_program] #[admin_authority] @@ -79,7 +79,7 @@ Auto-wrap is disabled; the consumer applies `#[require_not_frozen]` only to inst ```rust use spel_framework::prelude::*; use admin_authority::admin_authority; -use freeze_authority::{freeze_authority, require_not_frozen}; +use freeze_authority::{freeze_authority, require_not_frozen, FreezeCandidate}; #[lez_program] #[admin_authority] @@ -195,7 +195,7 @@ pub enum FreezeCandidate { Signer, /// The new freeze authority is a program-owned PDA. Validated by deriving /// the address from (program_id, seed) and confirming the PDA exists on chain. - Pda { program_id: AccountId, seed: [u8; 32] }, + Pda { program_id: ProgramId, seed: [u8; 32] }, } ``` From 13edb8d5a2da28c7f4447548a16da15f272fcaaa Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Mon, 10 Aug 2026 13:32:22 +0200 Subject: [PATCH 09/11] docs(extensions): correct the freeze guide against the shipped behavior The frozen-state callable list includes the per-account freeze edits and consumer exemptions, the consumer prerequisites match the admin guide (six crates, cargo fetch, no default fn main), and the two inits go back to back because a LEZ transaction carries a single instruction. The freeze-account target is documented as hex, releasing a target that is not frozen documents its refusal, Signer transfers walk the co-sign exchange, and the candidate Pda field says ProgramId. --- docs/lez/extensions/freeze-authority.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index 95f115ec..78b61185 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -17,7 +17,7 @@ sidebar_position: 3 This page is an early draft and may be incomplete or incorrect. Expect changes, missing prerequisites, and commands that might not work in your setup. We are actively working to complete and verify this content. ::: -`freeze-authority` is a SPEL extension that adds an emergency-stop primitive to your LEZ program. A designated freeze authority can pause all program execution (program-wide freeze) and block specific accounts from interacting (per-account freeze). The role can be transferred by the admin or renounced; while the program is frozen, only unfreeze, authority management, and admin operations remain callable. This page walks through using `freeze-authority` from an app developer's perspective. If you are building a different extension, see [Build a SPEL extension library](build-a-spel-extension-library.md) instead. +`freeze-authority` is a SPEL extension that adds an emergency-stop primitive to your LEZ program. A designated freeze authority can pause all program execution (program-wide freeze) and block specific accounts from interacting (per-account freeze). The role can be transferred by the admin or renounced; while the program is frozen, only the freeze management carve-outs (unfreeze, authority transfer and renounce, per-account freeze edits), admin operations, and instructions you marked `#[freeze_exempt]` remain callable. This page walks through using `freeze-authority` from an app developer's perspective. If you are building a different extension, see [Build a SPEL extension library](build-a-spel-extension-library.md) instead. `freeze-authority` depends on `admin-authority`. The admin governs the freeze authority slot; the freeze authority governs the frozen flags. See [Gate program instructions with admin-authority](admin-authority.md) for the admin layer. @@ -40,9 +40,14 @@ In your program's `Cargo.toml`: admin-authority = { git = "https://github.com/mmlado/spel-admin-authority" } freeze-authority = { git = "https://github.com/mmlado/spel-freeze-authority" } spel-framework = { git = "https://github.com/logos-co/spel" } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0", package = "lee_core" } +borsh = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive"] } ``` -The `admin-authority` dependency is required because freeze-authority composes with it. The `freeze-authority-macros` sub-crate is pulled in transitively. +The `admin-authority` dependency is required because freeze-authority composes with it, and both must be direct dependencies, the framework never discovers extensions transitively. `nssa_core` carries the on-chain account types, `borsh` encodes your state, and `serde` is required by the instruction plumbing. The `freeze-authority-macros` sub-crate is pulled in transitively. + +After adding the dependencies, run `cargo fetch` once. The framework's extension scanner resolves your dependency graph with an offline metadata call, which fails deterministically for a fresh consumer whose git dependencies were never fetched. And if you started from `cargo new`, delete the default `fn main`, the `#[lez_program]` macro generates the program's entry point. ## Annotate the module @@ -109,7 +114,7 @@ That single annotation pair (plus `#[admin_authority]`) exposes seven new instru | `freeze_account_release(target)` | Sets per-account frozen flag to false for `target`. Freeze authority only. Callable while frozen. | :::warning -**Initialization window.** Until `freeze_initialize` is called, the freeze Config PDA does not exist and no gates are active. Unlike `admin_initialize`, freeze initialization is NOT front-runnable — `freeze_initialize` requires the admin's signature. But it does require `admin_initialize` to have run first. Recommended pattern: bundle `admin_initialize` and `freeze_initialize` in the same transaction immediately after deployment. +**Initialization window.** Until `freeze_initialize` is called, the freeze Config PDA does not exist and no gates are active. Unlike `admin_initialize`, freeze initialization is NOT front-runnable — `freeze_initialize` requires the admin's signature. But it does require `admin_initialize` to have run first. Recommended pattern: submit `admin_initialize` and then `freeze_initialize` as the first two transactions after deployment, back to back. A LEZ transaction carries a single instruction, so they cannot share one. ::: ## Gate an instruction @@ -175,14 +180,16 @@ Both require the current freeze authority to sign. ```bash # Block account X from interacting with this program. spel --idl program-idl.json --program -- \ - freeze-account --caller --target + freeze-account --caller --target # Restore X's access. spel --idl program-idl.json --program -- \ - freeze-account-release --caller --target + freeze-account-release --caller --target ``` -When account X is frozen, any instruction in your program that's auto-gated or carries `#[require_not_frozen]` rejects when X is the signer. Other accounts are unaffected. Per-account state survives the program-wide frozen flag toggling — the two layers are independent. +`target` is a raw 32 byte argument, pass the account id as 64 hex characters, not base58. + +When account X is frozen, any instruction in your program that's auto-gated or carries `#[require_not_frozen]` rejects when X is the signer. Other accounts are unaffected. Per-account state survives the program-wide frozen flag toggling — the two layers are independent. Releasing a target that is not currently frozen rejects with `account is not frozen`, so a release cannot silently create marker state for untouched accounts. ## Transfer freeze authority to another party @@ -209,6 +216,8 @@ spel --idl program-idl.json --program -- \ --candidate Signer ``` +A `Signer` candidate is validated on chain by checking that the new holder co-signed the transaction, and the wallet only collects signatures for declared signer accounts. Collect the new holder's signature with the multi-signature exchange flow: export the partial transaction with the candidate named as a co-signer (`--export handover.json --co-signer `), send the file to the candidate to run `spel sign`, then submit it. The single command above builds and submits directly, and the sequencer drops it unless the new holder's signature is attached. + ## Use a program (PDA) as freeze authority To delegate freeze authority to another program (e.g. a multisig or a circuit-breaker DAO), pass `FreezeCandidate::Pda`: @@ -312,7 +321,7 @@ Plus your own instructions. In embedded mode neither `admin_initialize` nor `fre ## Security notes -- **Initialization order matters.** `freeze_initialize` requires admin signature and an initialized `admin_config`. Bundle both inits in the same transaction immediately after deployment. +- **Initialization order matters.** `freeze_initialize` requires admin signature and an initialized `admin_config`. Submit both inits back to back immediately after deployment, admin first. - **Renounce is recoverable (unlike admin).** Vacating the freeze authority slot is reversible by the admin via `freeze_authority_transfer`. Plan accordingly if your operational model assumes the role is permanent — only renouncing admin first locks it down. - **Exempt is shallow.** A `#[freeze_exempt]` consumer function that uses `chained_call` to invoke a gated function still hits the gated function's check. Frozen-state behaviour of chained calls is determined by the called function's exemption status, not the caller's. - **Auto mode covers all dispatched instructions.** Including admin operations? No — admin-authority's three management instructions are exempt by an explicit entry in freeze-authority's metadata. Admin can still transfer or renounce while the program is frozen. This is by design to avoid deadlock from a lost admin key during freeze. From 992cd402f55912bb11994ed80f1efbb211e9fb90 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 21 Aug 2026 10:57:44 +0200 Subject: [PATCH 10/11] docs(extensions): both pages build as written on a clean install The dependency blocks pin the fork revision the libraries pin, a branch reference fails even at the same commit because cargo keys git sources by reference kind. The admin page pins the v0.1.0 tag like the freeze page. A prerequisites section lists the packages the dependency tree and the spel CLI need, and the CLI gains install instructions, the package is named spel, not spel-cli. Marker imports and use super::* leave the snippets, the scanner consumes markers during expansion. The embedded snippets import AdminConfig, FreezeConfig, and FreezeCandidate, the generated transfer instruction references the candidate type even when consumer code never names it. The gate section shows a real body and states the post-state contract, declared accounts in declaration order, injected gate accounts appended automatically. Build and IDL steps are verified on a clean Ubuntu 24.04 with rustc 1.98, the lifecycle commands stay behind the draft banner. --- docs/lez/extensions/admin-authority.md | 100 +++++++++++++++--------- docs/lez/extensions/freeze-authority.md | 61 ++++++++++----- 2 files changed, 107 insertions(+), 54 deletions(-) diff --git a/docs/lez/extensions/admin-authority.md b/docs/lez/extensions/admin-authority.md index 680eb124..294ca126 100644 --- a/docs/lez/extensions/admin-authority.md +++ b/docs/lez/extensions/admin-authority.md @@ -29,23 +29,45 @@ Pick `admin-authority` when your program has: If your program needs multi-party approval rather than single-admin gating, `admin-authority` is the wrong primitive, wait for `multisig-authority` (RFP-TBD) or compose admin-authority with a multisig PDA as the admin. +## Prerequisites + +You need a stable Rust toolchain, git, and the native build tools the dependency tree leans on. The `spel` CLI additionally needs `unzip` (a build script unpacks a prebuilt rapidsnark archive) and the Python development library (the CLI links against libpython). The verification step at the end uses `jq`. On a fresh Ubuntu 24.04 this covers everything: + +```bash +sudo apt-get install curl git build-essential pkg-config libssl-dev ca-certificates unzip python3 python3-dev cmake jq +``` + +The build and IDL verification steps on this page were verified on a clean Ubuntu 24.04 with rustc 1.98. The lifecycle commands have not been run against a live node yet. + ## Add the dependency In your program's `Cargo.toml`: ```toml [dependencies] -admin-authority = { git = "https://github.com/mmlado/spel-admin-authority" } -spel-framework = { git = "https://github.com/logos-co/spel" } +admin-authority = { git = "https://github.com/mmlado/spel-admin-authority", tag = "v0.1.0" } +spel-framework = { git = "https://github.com/mmlado/spel", rev = "f7aa464b2c6c72ef513a25ede16584bca85b722f" } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0", package = "lee_core" } borsh = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] } ``` -All five are needed: the reference samples use exactly this set. `nssa_core` carries the on-chain account types, `borsh` encodes your state, and `serde` is required by the instruction plumbing even when your own types never touch it. The `admin-authority-macros` sub-crate is pulled in transitively. You do not need to declare it directly. The library README documents the framework revision each release is verified against. +All five are needed: the reference samples use exactly this set. `nssa_core` carries the on-chain account types, `borsh` encodes your state, and `serde` is required by the instruction plumbing even when your own types never touch it. The `admin-authority-macros` sub-crate is pulled in transitively. You do not need to declare it directly. + +The `spel-framework` entry points at a fork on purpose. It must be the exact revision `admin-authority` itself pins, and the library README documents that revision for each release. Pointing at `logos-co/spel` instead puts two copies of the framework into your dependency graph, and the build fails with a `From` trait error plus name resolution errors inside the `require_admin` expansion. The dependency moves to `logos-co/spel` once the extension mechanism lands upstream ([logos-co/spel#257](https://github.com/logos-co/spel/pull/257)). After adding the dependencies, run `cargo fetch` once. The framework's extension scanner resolves your dependency graph with an offline metadata call, which fails deterministically for a fresh consumer whose git dependencies were never fetched. +## Install the spel CLI + +The lifecycle commands below and the IDL check at the end use the `spel` CLI. Install it from the same fork revision the framework dependency pins: + +```bash +cargo install --git https://github.com/mmlado/spel --rev f7aa464b2c6c72ef513a25ede16584bca85b722f spel +``` + +The package name is `spel`, not `spel-cli` as the repository directory suggests, asking cargo for `spel-cli` fails with "could not find `spel-cli`". + ## Annotate the module If you started from `cargo new`, delete the default `fn main` first. The `#[lez_program]` macro generates the program's entry point, and the leftover stub collides with it as a duplicate `main`. @@ -54,21 +76,19 @@ Add `#[admin_authority]` inside your `#[lez_program]` module: ```rust use spel_framework::prelude::*; -use admin_authority::{admin_authority, require_admin}; #[lez_program] #[admin_authority] mod my_program { - use super::*; - #[instruction] pub fn create_pool( #[account(init, pda = literal("pool"))] pool: AccountWithMetadata, - #[account(signer)] caller: AccountWithMetadata, ) -> SpelResult { /* ... */ } } ``` +Nothing is imported from the library at this point. The `#[admin_authority]` marker is consumed by the framework's scanner during expansion, not resolved as an import, so importing the name only earns an unused import warning. The gate attribute gets imported when the first instruction uses it, next section. The module body does not need `use super::*;` either, the macro resolves paths to items declared outside the module on its own. + That single annotation exposes three new instructions in your program's IDL: | Instruction | Purpose | @@ -86,32 +106,45 @@ That single annotation exposes three new instructions in your program's IDL: Add `#[require_admin]` to any instruction that should only succeed when the caller is the current admin: ```rust +#[account_type] +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug)] +pub struct PoolConfig { + pub fee_bps: u16, +} + +// ... inside the #[lez_program] module: +use admin_authority::require_admin; + #[instruction] #[require_admin] pub fn set_fee_bps( #[account(mut, pda = literal("pool_config"))] mut config: AccountWithMetadata, new_fee_bps: u16, ) -> SpelResult { - // Admin check has already run. Just mutate. - todo!() + // The admin check has already run. + PoolConfig { fee_bps: new_fee_bps }.write_to(&mut config)?; + Ok(SpelOutput::execute(vec![config], vec![])) } ``` -The gate needs two accounts, the `admin_config` PDA holding the current admin state and a signing `caller`. You do not have to write them: the framework injects both from metadata the library declares, and they appear in the IDL like declared parameters. Declaring them explicitly produces the same program: +The `write_to` helper is yours to write, the library does not provide it. The reference sample uses this one: ```rust -#[instruction] -#[require_admin] -pub fn set_fee_bps( - #[account(pda = literal("admin_config"))] admin_config: AccountWithMetadata, - #[account(signer)] caller: AccountWithMetadata, - #[account(mut, pda = literal("pool_config"))] mut config: AccountWithMetadata, - new_fee_bps: u16, -) -> SpelResult { - todo!() +impl PoolConfig { + fn write_to(&self, account: &mut AccountWithMetadata) -> Result<(), SpelError> { + account.account.data = borsh::to_vec(self) + .map_err(|_| SpelError::SerializationError { message: "encoding failed".into() })? + .try_into() + .map_err(|_| SpelError::SerializationError { message: "data too large".into() })?; + Ok(()) + } } ``` +The `#[account_type]` struct sits outside the `#[lez_program]` module, the instruction inside it. The handler returns `Ok(SpelOutput::execute(post_states, messages))`, where `post_states` lists your declared accounts in declaration order. The injected `admin_config` and `caller` are appended to the post-states automatically, you only handle the parameters you wrote. + +The gate needs two accounts, the `admin_config` PDA holding the current admin state and a signing `caller`. You do not have to write them: the framework injects both from metadata the library declares, and they appear in the IDL like declared parameters. Declaring them explicitly produces the same program, and then they are your parameters, appearing in your post-states list like any other account. + If your instruction already has parameters by different names, point the gate at them with the inject-account names as keys: `#[require_admin(admin_config = my_cfg, caller = owner)]`. The framework also recognises declared parameters by role, a `#[account(signer)]` parameter or a PDA parameter with the matching seed is reused under its declared name instead of being injected twice. ## Become the first admin @@ -173,6 +206,8 @@ The PDA must already exist on chain as a claimed account, an unclaimed candidate 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: ```rust +use admin_authority::AdminConfig; + #[account_type] #[derive(BorshSerialize, BorshDeserialize, Clone, Debug)] pub struct ProgramConfig { @@ -191,28 +226,21 @@ mod my_program { #[instruction] pub fn initialize( #[account(init, pda = literal("program_config"))] mut config: AccountWithMetadata, - #[account(signer)] signer: AccountWithMetadata, ) -> SpelResult { - ProgramConfig { value: 0, padding: [0; 24], admin: AdminConfig::default() } - .write_to(&mut config)?; - // ... + ProgramConfig { + value: 0, + padding: [0; 24], + admin: AdminConfig::default(), + } + .write_to(&mut config)?; + // the signing caller is injected, and the injected bootstrap + // installs it as admin in this same transaction + Ok(SpelOutput::execute(vec![config], vec![])) } } ``` -The `write_to` helper is yours to write, the library does not provide it. The reference sample uses this one: - -```rust -impl ProgramConfig { - fn write_to(&self, account: &mut AccountWithMetadata) -> Result<(), SpelError> { - account.account.data = borsh::to_vec(self) - .map_err(|_| SpelError::SerializationError { message: "encoding failed".into() })? - .try_into() - .map_err(|_| SpelError::SerializationError { message: "data too large".into() })?; - Ok(()) - } -} -``` +`write_to` is the same helper pattern from the gating section, implemented on `ProgramConfig`. What changes: diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index 78b61185..9a3b34eb 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -31,21 +31,27 @@ Pick `freeze-authority` when your program needs: If your program needs a permanent pause with no recovery, use `admin_renounce` after deployment instead — freeze-authority is the wrong primitive for one-way upgrades. +## Prerequisites + +Same toolchain as the admin-authority page: a stable Rust toolchain, git, the native build packages, and the `spel` CLI. See [Prerequisites](admin-authority.md#prerequisites) and [Install the spel CLI](admin-authority.md#install-the-spel-cli) there. Everything below assumes those are in place. + +The build and IDL verification steps on this page were verified on a clean Ubuntu 24.04 with rustc 1.98, in auto, manual, and embedded mode. The lifecycle commands have not been run against a live node yet. + ## Add the dependency In your program's `Cargo.toml`: ```toml [dependencies] -admin-authority = { git = "https://github.com/mmlado/spel-admin-authority" } -freeze-authority = { git = "https://github.com/mmlado/spel-freeze-authority" } -spel-framework = { git = "https://github.com/logos-co/spel" } +admin-authority = { git = "https://github.com/mmlado/spel-admin-authority", tag = "v0.1.0" } +freeze-authority = { git = "https://github.com/mmlado/spel-freeze-authority", tag = "v0.1.0" } +spel-framework = { git = "https://github.com/mmlado/spel", rev = "f7aa464b2c6c72ef513a25ede16584bca85b722f" } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0", package = "lee_core" } borsh = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] } ``` -The `admin-authority` dependency is required because freeze-authority composes with it, and both must be direct dependencies, the framework never discovers extensions transitively. `nssa_core` carries the on-chain account types, `borsh` encodes your state, and `serde` is required by the instruction plumbing. The `freeze-authority-macros` sub-crate is pulled in transitively. +The `admin-authority` dependency is required because freeze-authority composes with it, and both must be direct dependencies, the framework never discovers extensions transitively. Both libraries pin their `v0.1.0` release tags. The framework must be the exact revision those releases pin, spelled as `rev = ...`. A branch reference fails even when the branch points at the same commit, because cargo treats different git reference kinds as different sources and you end up with two copies of the framework and a `From` trait error. The source flips to `logos-co/spel` once the extension mechanism reaches an upstream release ([logos-co/spel#257](https://github.com/logos-co/spel/pull/257)). `nssa_core` carries the on-chain account types, `borsh` encodes your state, and `serde` is required by the instruction plumbing. The `freeze-authority-macros` sub-crate is pulled in transitively. After adding the dependencies, run `cargo fetch` once. The framework's extension scanner resolves your dependency graph with an offline metadata call, which fails deterministically for a fresh consumer whose git dependencies were never fetched. And if you started from `cargo new`, delete the default `fn main`, the `#[lez_program]` macro generates the program's entry point. @@ -58,16 +64,13 @@ After adding the dependencies, run `cargo fetch` once. The framework's extension Every dispatched instruction except the F3 carve-outs and admin operations is automatically gated by the freeze check. Consumers opt OUT per instruction with `#[freeze_exempt]`. ```rust +use freeze_authority::{freeze_exempt, FreezeCandidate}; use spel_framework::prelude::*; -use admin_authority::admin_authority; -use freeze_authority::{freeze_authority, freeze_exempt, FreezeCandidate}; #[lez_program] #[admin_authority] #[freeze_authority] mod my_program { - use super::*; - #[instruction] pub fn transfer(/* ... */) -> SpelResult { /* ... */ } // auto-gated @@ -77,21 +80,20 @@ mod my_program { } ``` +The `#[admin_authority]` and `#[freeze_authority]` markers are not imported, the framework's scanner consumes them during expansion, importing those names only earns unused import warnings. `freeze_exempt` and `FreezeCandidate` are real imports. `FreezeCandidate` is required even though your own code never names it, the generated `freeze_authority_transfer` instruction references it. The module body does not need `use super::*;`. + ### Manual mode Auto-wrap is disabled; the consumer applies `#[require_not_frozen]` only to instructions they want gated. F3 conformance becomes the consumer's responsibility. ```rust +use freeze_authority::{require_not_frozen, FreezeCandidate}; use spel_framework::prelude::*; -use admin_authority::admin_authority; -use freeze_authority::{freeze_authority, require_not_frozen, FreezeCandidate}; #[lez_program] #[admin_authority] #[freeze_authority(manual)] mod my_program { - use super::*; - #[instruction] #[require_not_frozen] pub fn transfer(/* ... */) -> SpelResult { /* ... */ } // explicitly gated @@ -249,15 +251,18 @@ If the admin has already been renounced first (terminal), the freeze slot become Like admin-authority, the freeze state can live inside one of your program's own accounts instead of a dedicated Config PDA, and both extensions can share the same account at distinct offsets: ```rust +use admin_authority::AdminConfig; +use freeze_authority::{FreezeCandidate, FreezeConfig}; + #[account_type] #[derive(BorshSerialize, BorshDeserialize, Clone, Debug)] pub struct ProgramConfig { pub value: u64, // bytes 0..8 pub padding: [u8; 24], // bytes 8..32 #[admin_slot] - pub admin: AdminConfig, // bytes 32..64 + pub admin: AdminConfig, // bytes 32..64, the admin slot #[freeze_slot] - pub freeze: FreezeConfig, // bytes 64..97 + pub freeze: FreezeConfig, // bytes 64..97, the freeze slot } #[lez_program] @@ -270,11 +275,31 @@ mod my_program { #[instruction] pub fn initialize( #[account(init, pda = literal("program_config"))] mut config: AccountWithMetadata, - #[account(signer)] signer: AccountWithMetadata, ) -> SpelResult { - // write the struct with both slots defaulted; the admin - // bootstrap is injected by the attribute - // ... + ProgramConfig { + value: 0, + padding: [0; 24], + admin: AdminConfig::default(), + freeze: FreezeConfig::default(), + } + .write_to(&mut config)?; + // the signing caller is injected, the injected bootstrap + // installs it as admin, and the freeze slot stays vacant + Ok(SpelOutput::execute(vec![config], vec![])) + } +} +``` + +The `write_to` helper is yours to write, the library does not provide it. The reference sample uses this one: + +```rust +impl ProgramConfig { + fn write_to(&self, account: &mut AccountWithMetadata) -> Result<(), SpelError> { + account.account.data = borsh::to_vec(self) + .map_err(|_| SpelError::SerializationError { message: "encoding failed".into() })? + .try_into() + .map_err(|_| SpelError::SerializationError { message: "data too large".into() })?; + Ok(()) } } ``` From 57e4d1687f4a0dd0510ef9ae04455d7ea049e1da Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 21 Aug 2026 14:08:14 +0200 Subject: [PATCH 11/11] docs(extensions): scope the safety claims and complete the library guide The refuses-loudly guarantee applies to scanner-carrying framework revisions, on upstream main the marker is ignored and the program builds silently, so a missing surface points at the framework pin first. The spel binary must be built from the pinned revision, a scanner-less CLI omits every extension instruction with a clean exit. The draft banners state that the pages track unreleased code on a fork. The consumer snippet pins the fork revision and drops the marker import. The runtime library snippet carries its dependency block and names the state parameter after the inject role, injection, wrap stamping, and embedded retargeting resolve accounts by that name and a mismatched name fails the consumer's compile in embedded mode. Library bodies return inner account values, not the wrappers consumer handlers return. The gate helpers are hand-written and the gate is re-exported next to the marker. The wrap covers the extension's own instructions, which dispatch cross-crate, so an own instruction either covers the inject roles or carries the self exempt marker, the way freeze-authority's release and transfer ops carry freeze_exempt. --- docs/lez/extensions/admin-authority.md | 6 +++- .../build-a-spel-extension-library.md | 28 +++++++++++++++---- docs/lez/extensions/freeze-authority.md | 4 +++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/lez/extensions/admin-authority.md b/docs/lez/extensions/admin-authority.md index 294ca126..102e7af2 100644 --- a/docs/lez/extensions/admin-authority.md +++ b/docs/lez/extensions/admin-authority.md @@ -15,6 +15,8 @@ sidebar_position: 1 :::warning This page is an early draft and may be incomplete or incorrect. Expect changes, missing prerequisites, and commands that might not work in your setup. We are actively working to complete and verify this content. + +This page tracks unreleased code. The dependency snippets pin a personal fork of the framework and pre-release library tags. The pins move to logos-co sources once the extension mechanism lands upstream ([logos-co/spel#257](https://github.com/logos-co/spel/pull/257)). ::: `admin-authority` is a SPEL extension that adds a single transferable admin role to your LEZ program. The admin is the only account allowed to call admin-gated instructions. The role can be transferred to another signer or PDA, or renounced permanently. This page walks through using `admin-authority` from an app developer's perspective. If you are building a different extension, see [Build a SPEL extension library](build-a-spel-extension-library.md) instead. @@ -269,6 +271,8 @@ After building your program, check that the admin instructions appear in the IDL spel generate-idl path/to/your/program/src/main.rs | jq '.instructions[].name' ``` +The `spel` binary must be built from the same framework revision your `Cargo.toml` pins. A CLI built without the extension scanner omits the admin instructions from this output without reporting an error, so the check appears to pass while the surface is missing. The install command in [Install the spel CLI](#install-the-spel-cli) pins the right revision. + Expected output includes: ``` @@ -277,7 +281,7 @@ Expected output includes: "admin_renounce" ``` -Plus your own instructions. A marker that matches no discoverable extension is a hard compile error naming the marker, so a broken setup refuses loudly rather than building without the trio. When you hit that error, the most common causes are: +Plus your own instructions. On a framework build that carries the extension scanner, a marker that matches no discoverable extension is a hard compile error naming the marker, so a broken setup refuses loudly rather than building without the trio. That safety net is a property of the pinned framework revision: on a framework without the scanner, upstream `logos-co/spel` main today, the marker is ignored and the program builds cleanly without the trio. When you hit the hard error, the most common causes are: - `admin-authority` not declared as a direct path or git dependency in your `Cargo.toml`. Transitive dependencies are never discovered. - `#[admin_authority]` placed outside `#[lez_program]` rather than inside. diff --git a/docs/lez/extensions/build-a-spel-extension-library.md b/docs/lez/extensions/build-a-spel-extension-library.md index 86fa3c11..483a8861 100644 --- a/docs/lez/extensions/build-a-spel-extension-library.md +++ b/docs/lez/extensions/build-a-spel-extension-library.md @@ -15,6 +15,8 @@ sidebar_position: 2 :::warning This page is an early draft and may be incomplete or incorrect. Expect changes, missing prerequisites, and commands that might not work in your setup. We are actively working to complete and verify this content. + +This page tracks unreleased code. The dependency snippets pin a personal fork of the framework. The pin moves to logos-co sources once the extension mechanism lands upstream ([logos-co/spel#257](https://github.com/logos-co/spel/pull/257)). ::: SPEL extension libraries ship reusable on-chain primitives, access control, freeze switches, multisig, etc., that consuming programs adopt with a single attribute. This guide is for library authors. App developers consuming an existing extension should follow that extension's own integration guide instead. @@ -59,6 +61,11 @@ edition = "2021" [package.metadata.spel] extension_attr = "my_extension" + +[dependencies] +borsh = { version = "1", features = ["derive"] } +spel-framework = { git = "https://github.com/mmlado/spel", rev = "f7aa464b2c6c72ef513a25ede16584bca85b722f" } +my-extension-macros = { path = "../my-extension-macros" } ``` - `extension_attr` is the attribute name consumers put on their `#[lez_program]` module to opt in. By convention, match it to your crate name (with `_` not `-`). @@ -84,7 +91,7 @@ pub struct MyState { #[instruction] pub fn extension_action( - #[account(mut, pda = literal("my_state"))] mut state: AccountWithMetadata, + #[account(mut, pda = literal("my_state"))] mut my_state: AccountWithMetadata, #[account(signer)] caller: AccountWithMetadata, new_value: u64, ) -> SpelResult { @@ -97,6 +104,8 @@ Three things to note: - `extern crate self as my_extension;`, lets the library reference its own types via the absolute path `::my_extension::MyState`. The framework emits cross-crate calls into the consumer's binary using that path, so the path needs to resolve both in the library's own compile and at the consumer's compile. - `pub use my_extension_macros::{instruction, my_extension};`, re-exports the marker attribute and the no-op `#[instruction]` shim so consumers (and the library's own `lib.rs`) can use them without importing the macros crate directly. - `#[account(...)]` attributes on parameters, these are framework helper attributes that describe PDA seeds, signer requirements, etc. The library's own `#[instruction]` shim strips them at the library's compile so rustc accepts the source; the framework reads them during the path-dep scan. +- Name the state parameter after the inject role you will declare for it (`my_state` here). Injection reuse, wrap stamping, and embedded retargeting resolve your accounts by role name, a differently named parameter breaks embedded mode with an argument-count error at the consumer's compile. +- When you write the real body, post-states are the inner `account` values (`vec![my_state.account, caller.account]`), with an `(account, AutoClaim)` tuple for accounts the instruction claims. Consumer handlers return the `AccountWithMetadata` wrappers, library handlers do not. The reference samples show both patterns. ## Define the proc-macro sub-crate @@ -173,6 +182,8 @@ pub fn require_my_gate(_attr: TokenStream, item: TokenStream) -> TokenStream { } ``` +The `from_account` and `assert_allowed` helpers the prologue calls are yours to write on `MyState`, the framework provides neither. Re-export the gate from the runtime library next to the marker (`pub use my_extension_macros::require_my_gate;`) so consumers import everything from one crate. + Never read or strip `#[account(...)]` attributes in a gate macro. That attribute belongs to the framework, which reads it for validation and the IDL. Your gate should only reference parameter names, taken from its own attribute args with sensible defaults. **The kwarg contract.** Your gate's attribute keys must be exactly the inject-account names you declare in metadata (`#[require_my_gate(my_state = their_cfg, caller = owner)]`). The framework's auto-wrap and gate stamping emit every kwarg with the resolved parameter name, so your macro receives the framework's naming decisions instead of guessing from convention. Ship an alignment self-test so the two cannot drift: read your own metadata with `spel_framework_core::extension::read_inject_specs(Path::new(env!("CARGO_MANIFEST_DIR")))` and assert the declared account names equal the kwarg set a probe function hands your gate. A name the macro rejects fails the probe's compile, a metadata rename fails the runtime assert. @@ -217,6 +228,8 @@ exempt = [ When the consumer puts `#[my_extension]` on their `#[lez_program]` mod, the framework walks the dispatcher table and prepends `#[require_my_gate]` to every function that is not in `exempt` and does not carry `#[my_extension_exempt]`. Consumers write normal code, the gate arrives with the wrap. Injection and wrapping compose: a wrapped instruction gets your gate's parameters injected like an annotated one. +The wrap covers your own extension's instructions too, and those dispatch cross-crate, so the dispatcher cannot add a parameter to your library's function. A wrapped own instruction whose parameter list does not cover your inject roles, by role name or matching seed, fails the consumer's compile with an argument-count error on the dispatch call. Own instructions that lack your gate accounts, or that must stay callable while your gate rejects, carry your own `self_exempt_marker` attribute in the library source, the way freeze-authority's release and transfer ops carry `#[freeze_exempt]`. + The hook is opt-in, omit `wrap_instructions` from your metadata and the framework leaves all instructions alone. This is the right choice for most extensions (pure data primitives, single-instruction gates, etc.). ## Embedded mode (optional) @@ -227,7 +240,7 @@ An extension whose per-program state is one fixed-size slot can let consumers em #[my_extension(my_state = config, offset = 32)] ``` -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: +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, keep the state parameter named after its inject role (retargeting resolves it by that name), and declare the offset 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 [package.metadata.spel.embedded] @@ -280,16 +293,17 @@ A consumer adds your extension to their `Cargo.toml`: ```toml [dependencies] my-extension = { git = "https://github.com/you/my-extension" } -spel-framework = { git = "https://github.com/logos-co/spel" } +spel-framework = { git = "https://github.com/mmlado/spel", rev = "f7aa464b2c6c72ef513a25ede16584bca85b722f" } ``` +The `spel-framework` pin must be a revision that carries the extension scanner, and it must be the exact revision your library pins, spelled the same way. Upstream `logos-co/spel` does not have the scanner until [logos-co/spel#257](https://github.com/logos-co/spel/pull/257) lands, and a branch reference fails to unify with a rev pin even at the same commit, cargo keys git sources by reference kind. Swap this for the `logos-co` URL once the mechanism reaches an upstream release. + Path, git, and registry dependencies are all discoverable. Discovery is restricted to the consumer's direct dependencies, a transitive crate can never contribute instructions by claiming a matching `extension_attr`, and the generated call paths use your `[package].name`, never a directory name. Then puts the marker on their `#[lez_program]` module: ```rust use spel_framework::prelude::*; -use my_extension::my_extension; #[lez_program] #[my_extension] @@ -299,6 +313,8 @@ mod my_program { } ``` +The marker is matched by attribute name only, nothing is imported for it. `use my_extension::my_extension;` would only earn an unused import warning. Gate attributes and types your extension expects consumers to name in code are real imports, document those in your library's README. + After compilation, the consumer's binary contains your extension's instructions in its `Instruction` enum, dispatcher, and `PROGRAM_IDL_JSON` const. `spel generate-idl` shows them too. The extension's source is never copied into the consumer's module; calls dispatch directly to your library via `::my_extension::extension_action(...)`. ## Multiple extensions on one program @@ -324,7 +340,9 @@ Build a small sample program that consumes your extension. Then: spel generate-idl path/to/sample/src/main.rs ``` -The IDL should contain your extension's instructions alongside the consumer's own. A marker that matches no discoverable extension is a hard compile error naming the marker, regardless of why it did not match, so a broken setup refuses loudly instead of building a program silently missing its extension surface. When you hit that error, the most common causes are: +The IDL should contain your extension's instructions alongside the consumer's own. The `spel` binary must itself be built from a scanner-carrying framework revision, a CLI without the scanner omits every extension instruction from this output without reporting an error. + +On a framework build that carries the extension scanner, a marker that matches no discoverable extension is a hard compile error naming the marker, regardless of why it did not match, so a broken setup refuses loudly instead of building a program silently missing its extension surface. The fail-closed behaviour is a property of the framework revision, not of the mechanism: on a build without the scanner the marker is ignored and the program compiles without your extension. An author debugging a missing surface should check the framework pin before the metadata. When you hit the hard error, the most common causes are: - `[package.metadata.spel.extension_attr]` not declared, or value does not match the attribute name the consumer wrote. - The library is a transitive dependency rather than a direct one. Only the consumer's own `[dependencies]` are scanned, by design. diff --git a/docs/lez/extensions/freeze-authority.md b/docs/lez/extensions/freeze-authority.md index 9a3b34eb..48070fcf 100644 --- a/docs/lez/extensions/freeze-authority.md +++ b/docs/lez/extensions/freeze-authority.md @@ -15,6 +15,8 @@ sidebar_position: 3 :::warning This page is an early draft and may be incomplete or incorrect. Expect changes, missing prerequisites, and commands that might not work in your setup. We are actively working to complete and verify this content. + +This page tracks unreleased code. The dependency snippets pin a personal fork of the framework and pre-release library tags. The pins move to logos-co sources once the extension mechanism lands upstream ([logos-co/spel#257](https://github.com/logos-co/spel/pull/257)). ::: `freeze-authority` is a SPEL extension that adds an emergency-stop primitive to your LEZ program. A designated freeze authority can pause all program execution (program-wide freeze) and block specific accounts from interacting (per-account freeze). The role can be transferred by the admin or renounced; while the program is frozen, only the freeze management carve-outs (unfreeze, authority transfer and renounce, per-account freeze edits), admin operations, and instructions you marked `#[freeze_exempt]` remain callable. This page walks through using `freeze-authority` from an app developer's perspective. If you are building a different extension, see [Build a SPEL extension library](build-a-spel-extension-library.md) instead. @@ -322,6 +324,8 @@ After building your program, check that the freeze instructions appear in the ID spel generate-idl path/to/your/program/src/main.rs | jq '.instructions[].name' ``` +The `spel` binary must be built from the same framework revision your `Cargo.toml` pins. A CLI built without the extension scanner omits every extension instruction from this output without reporting an error. + Expected output includes: ```