diff --git a/docs/lez/extensions/admin-authority.md b/docs/lez/extensions/admin-authority.md index 680eb124..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. @@ -29,23 +31,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 +78,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 +108,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 +208,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 +228,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: @@ -241,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: ``` @@ -249,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 fb61dbd7..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. @@ -196,17 +207,30 @@ 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 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) @@ -216,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] @@ -237,6 +261,31 @@ 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 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 +#[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 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. + +## 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`: @@ -244,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] @@ -263,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 @@ -288,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 new file mode 100644 index 00000000..48070fcf --- /dev/null +++ b/docs/lez/extensions/freeze-authority.md @@ -0,0 +1,361 @@ +--- +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 +--- + +# 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. + +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. + +`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. + +## 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", 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. 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. + +## 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 freeze_authority::{freeze_exempt, FreezeCandidate}; +use spel_framework::prelude::*; + +#[lez_program] +#[admin_authority] +#[freeze_authority] +mod my_program { + #[instruction] + pub fn transfer(/* ... */) -> SpelResult { /* ... */ } // auto-gated + + #[instruction] + #[freeze_exempt] + pub fn balance_of(/* ... */) -> SpelResult { /* ... */ } // exempt — callable while frozen +} +``` + +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::*; + +#[lez_program] +#[admin_authority] +#[freeze_authority(manual)] +mod my_program { + #[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. | + +:::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: 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 + +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 function carrying the attribute. + +## Initialize the freeze authority + +`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 --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 --idl program-idl.json --program -- \ + freeze-program --caller + +# Unfreeze: restores normal operation. +spel --idl program-idl.json --program -- \ + freeze-program-release --caller +``` + +Both require the current freeze authority to sign. + +## Freeze and unfreeze a specific account + +```bash +# Block account X from interacting with this program. +spel --idl program-idl.json --program -- \ + freeze-account --caller --target + +# Restore X's access. +spel --idl program-idl.json --program -- \ + freeze-account-release --caller --target +``` + +`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 + +`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: ProgramId, 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 --idl program-idl.json --program -- \ + freeze-authority-transfer \ + --caller \ + --new-account \ + --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`: + +```bash +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 accepts the PDA as the legitimate freeze authority. + +## Renounce freeze authority + +```bash +# Either the current admin OR the current freeze authority can sign. +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 +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, the admin slot + #[freeze_slot] + pub freeze: FreezeConfig, // bytes 64..97, the freeze slot +} + +#[lez_program] +#[admin_authority(admin_config = config, offset = 32)] +#[freeze_authority(freeze_config = config, offset = 64)] +mod my_program { + use admin_authority::admin_initialize; + + #[admin_initialize] + #[instruction] + pub fn initialize( + #[account(init, pda = literal("program_config"))] mut config: AccountWithMetadata, + ) -> SpelResult { + 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(()) + } +} +``` + +What changes: + +- **No `freeze_initialize`.** Your account-creating instruction writes the struct, and the freeze slot is born vacant: it rejects every holder-path caller until the admin appoints the first holder via `freeze_authority_transfer`, the same path that repopulates a renounced slot. There is no initialization ordering to get right because there is no initializer. The admin slot next door is bootstrapped by marking that same instruction with `#[admin_initialize]`, the caller becomes admin in the transaction that creates the account. +- **Slot markers keep the layout honest.** `#[admin_slot]` and `#[freeze_slot]` each derive an offset const and a layout test, and the build fails if a marker position and its `offset = ...` declaration ever disagree, for example after a field is added above a slot. +- **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: + +```bash +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: + +``` +"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. 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). +- `#[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`. 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. +- **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.