Skip to content

Repository files navigation

SPEL Admin Authority

CI Consumer build Version License MSRV

Single-admin authority primitive for LEZ programs. Provides a standardised way to gate privileged instructions behind a transferable, renounceable admin, integrated as two SPEL macros so consumers add it with one or two annotations.

What it does

A program adds #[admin_authority] at the module level and #[require_admin] on each instruction it wants gated. The library ships the three management instructions (admin_initialize, admin_transfer, admin_renounce), and the framework discovers them at compile time via metadata declared in the library's Cargo.toml.

Status at this milestone (M2.5): the library is working, gate params are injected by the framework when a gated instruction does not declare them, and the admin slot can live either in its own Config PDA (dedicated mode, the default) or inside one of the consumer's own accounts at a byte offset (embedded mode, see below). All three reference samples pass behavioural tests.

use spel_framework::prelude::*;
use admin_authority::{admin_authority, require_admin};

#[lez_program]
#[admin_authority]
mod my_program {
    #[instruction]
    #[require_admin]
    pub fn update_value(
        #[account(pda = literal("admin_config"))] admin_config: AccountWithMetadata,
        #[account(signer)] caller: AccountWithMetadata,
        #[account(mut, pda = literal("program_config"))] mut config: AccountWithMetadata,
        new_value: u64,
    ) -> SpelResult {
        // handler body. The admin check runs before this.
    }
}

The gate reads two accounts: admin_config, the Config PDA, and caller, the signer. Declaring them is optional: a gated instruction that omits either gets it synthesized by the framework from the library's inject metadata, PDA-verified and part of the IDL. With different param names, pass them to the gate: #[require_admin(admin_config = my_cfg, caller = owner)].

Adding #[admin_authority] to the module exposes three new instructions in the IDL:

  • admin_initialize creates the Config PDA and installs the caller as the first admin (self-election, see ADR-0005).
  • admin_transfer replaces the current admin with a new one.
  • admin_renounce zeros the admin permanently. Terminal.

Adding #[require_admin] to an instruction marks it admin-gated: it inserts a check that decodes the admin config and asserts the caller is the current admin before the handler body runs.

Embedded mode

The admin slot can live inside one of the consumer's own accounts instead of a dedicated Config PDA. Declared program-wide on the marker, role kwarg plus byte offset:

#[account_type]
#[derive(BorshSerialize, BorshDeserialize, Clone, Debug)]
pub struct ProgConfig {
    pub value: u64,            // bytes 0..8
    pub padding: [u8; 24],     // bytes 8..32
    #[admin_slot]
    pub admin: AdminConfig,    // bytes 32..64, the embedded slot
}

#[lez_program]
#[admin_authority(admin_config = config, offset = 32)]
mod my_program {
    use admin_authority::admin_initialize;

    #[admin_initialize]
    #[instruction]
    pub fn initialize(
        #[account(init, pda = literal("prog_config"))] mut config: AccountWithMetadata,
        #[account(signer)] signer: AccountWithMetadata,
    ) -> SpelResult {
        ProgConfig { value: 0, padding: [0; 24], admin: AdminConfig::default() }
            .write_to(&mut config)?;
        // ...
    }
}

What changes versus dedicated mode:

  • No admin_initialize instruction. The consumer marks its own account-creating instruction with #[admin_initialize] and the bootstrap is injected: the caller is installed as admin in the transaction that creates the account, so the slot is born initialized and there is no init front-running window in embedded mode. An account created without the bootstrap is born renounced, permanently.
  • #[admin_slot] marks the field. The marker derives an ADMIN_SLOT_OFFSET const and a layout test, and the build fails if the marker position and the declared offset disagree, for example after a field is added above the slot.
  • Everything retargets. Gates read the slot at the declared offset from the embedding account, admin_transfer and admin_renounce operate on it (writes splice only the 32-byte window, neighboring consumer fields survive), and the IDL shows the embedding account everywhere the dedicated PDA used to appear.
  • The offset is never in a transaction. It is compiled into the program as a literal at every call site; the IDL carries no offset argument. Changing it means different bytecode, which on LEZ is a different program.
  • The marker is the only writer of location kwargs. Writing admin_config = ... or offset = ... on a gate by hand is a compile error in embedded mode; the caller kwarg stays available.
  • Layout obligations. The embedded AdminConfig field must sit at the declared offset with only fixed-size fields before it, and the embedding account must be declared under the marker's name in every instruction that declares it.

Embedded mode removes one account from every gated transaction. Design record: ADR-0007. Dry-run walkthrough: scripts/dry-run-embedded.sh, expected output in docs/dry-run-embedded-output.txt.

Layout

Crate Purpose
admin-authority Runtime library: AdminConfig, AdminCandidate, AdminError, the auth methods, and the three management instruction fns. Declares the discovery metadata.
admin-authority-macros Proc-macro sub-crate: #[admin_authority] (marker), #[require_admin] (injects the runtime admin check at the top of the handler body). Re-exported through admin-authority.
admin-authority-sample Reference SPEL program using both macros end to end, with declared gate params.
admin-authority-sample-manual Second reference program showing the manual path: no #[admin_authority] marker, self-elect initialize inside the consumer's own handler, hand-written transfer and renounce, fully declared gate params.
admin-authority-sample-embedded Third reference program: the admin slot embedded inside the consumer's own prog_config account at byte offset 32, bootstrapped by the consumer's initialize, management instructions retargeted by the framework.

Architecture

Framework knows nothing specific about admin-authority. A generic extension scanner in spel-framework-core walks the consumer's direct dependencies (path, git, or registry) looking for [package.metadata.spel] declarations:

# admin-authority/Cargo.toml
[package.metadata.spel]
extension_attr = "admin_authority"

When the consumer's #[lez_program] module carries #[admin_authority], the scanner reads admin-authority's src/lib.rs for #[instruction]-annotated fns and merges them into the consumer's dispatcher and IDL with cross-crate call paths (::admin_authority::admin_initialize(...)).

The #[require_admin] gate check is an ordinary proc-macro that re-expands on the emitted handler, which is how it injects its runtime check (ADR-0004). The gate's account params come from the library's inject metadata when a gated instruction does not declare them (ADR-0006); in embedded mode the framework additionally rewrites the admin_config role to the consumer's embedding account and stamps every gate with the location kwargs (ADR-0007).

The same mechanism powers any future extension such as freeze-authority, with no framework PR needed per library.

Adding as a dependency

The framework discovers extensions among the consumer's direct dependencies, whether they come by path, git, or registry. admin-authority must be a direct dependency; a transitive one is never discovered, by design.

[dependencies]
admin-authority = { git = "https://github.com/mmlado/spel-admin-authority", tag = "v0.1.3" }
spel-framework  = { git = "https://github.com/logos-co/spel", rev = "8183b011b1a00dbc73ca9209f6b902c622d899af" }

A local checkout referenced by path works the same way. admin-authority-macros is pulled in transitively via admin-authority, no need to declare it directly. The spel-framework source must match the revision this repo's Cargo.toml pins, spelt the same way, cargo treats different git reference kinds as different sources. The rev is the upstream commit that merged the extension mechanism (logos-co/spel#257). It becomes a tag once upstream cuts a release that contains it.

Integration steps

  1. Annotate the module with #[admin_authority] after #[lez_program]. The three admin instructions appear in the IDL automatically.
  2. Call admin_initialize immediately after deployment; the caller becomes admin. Bundling with the deploy is not possible on LEZ today (deployment transactions carry no instructions). Anything between deployment and the first admin_initialize is the initialization window; whoever calls first becomes admin. Want a different admin? Initialize, then admin_transfer.
  3. Gate instructions by adding #[require_admin]. Declaring the admin_config and caller params is optional, missing ones are injected. Custom names go through the gate's args: #[require_admin(admin_config = my_cfg, caller = owner)].
  4. Transfer or renounce via the injected admin_transfer and admin_renounce instructions. Transfer takes an AdminCandidate (signer or PDA) paired with the corresponding new_account.

The authority lifecycle document covers the state machine, validation rules at each transition, and the program-as-admin path through CPI.

Transaction size overhead

Measured from the committed dry-run captures (docs/dry-run-output.txt, docs/dry-run-embedded-output.txt).

Dedicated mode: the gate adds one account to a gated transaction, the admin_config PDA. That is 32 bytes of account id in the message plus the 32 byte config pre-state in the witness. The caller signer is shared with the application whenever it already requires one. Instruction data is unchanged, the check adds no arguments.

Embedded mode: the gate adds no account when the gated instruction already carries the embedding account. The slot adds 32 bytes to that account's data instead. In the captures, update_value carries 3 accounts in dedicated mode and 2 in embedded mode.

The embedded shapes are also confirmed end to end against a local LEZ v0.2.0 stack (bedrock node plus sequencer) with the program built from the published branch heads. The gated update_value and the fully synthesized poke each carried 2 accounts, the caller and the embedding account. Embedded mode also drops the separate admin_initialize transaction, the slot was bootstrapped inside the same initialize that created the account.

Security notes

  • Initialization window. Call admin_initialize immediately after deployment. Until that call lands, anyone can submit it and become admin. Bundling with the deployment is not possible on LEZ today (deployment transactions carry no instructions), so the window is structural.
  • Renounce is terminal. admin_renounce writes AccountId::default() and that is the end. No recovery path by design.
  • PDA admins via CPI. A program-owned PDA can be the admin. The owning program calls the gated instruction via a chained_call and declares its admin PDA in caller-pda-seeds; LEZ propagates is_authorized to the callee. See the lifecycle doc.
  • Transfer history. Not recorded on-chain. The current admin is always readable from the Config PDA; historical transfers require an off-chain indexer.
  • Signer transfers on-chain. A transfer to a Signer candidate needs the new admin's co-signature on the same transaction. The CLI exercises this in dry-run today; submitting it on-chain uses the multi-signature exchange flow proposed upstream in logos-co/spel#246.

Support

Clarification questions and in-scope fixes are covered for 90 days after milestone approval. Platform upgrades past the pinned LEZ rev are new scope.

Documentation

  • docs/authority-lifecycle.md: state machine, transitions, validation rules.
  • docs/adr/: architectural decision records (PDA seed, macro placement, self-election, gate check injection, param injection, embedded account support).
  • CONTEXT.md: domain language used throughout the project.

Development

cargo check --workspace
RISC0_DEV_MODE=1 cargo test --workspace
cargo expand -p admin-authority-sample

Versioning and stability

Semantic versioning covers four surfaces: the public Rust API, the attribute names (#[admin_authority], #[admin_initialize], #[admin_slot], #[require_admin]) with their argument grammar, the [package.metadata.spel] contract this crate declares, and AdminConfig's 32-byte borsh encoding, an on-chain wire format. While the version is 0.x, a minor bump may change any of them, and each such change is called out in the changelog.

The spel-framework dependency pins a fork revision for now. Version 1.0.0 lands when the extension mechanism reaches an upstream release (logos-co/spel#257) and the pin moves to it.

License

Dual-licensed under MIT and Apache 2.0 at the consumer's option.

About

Single-admin authority extension for SPEL programs: gated instructions, transferable and renounceable admin

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages