Skip to main content

Gate program instructions with admin-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. This content is still being completed and verified.

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).

Version

This document is accurate for Testnet v0.2.1.

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 instead.

When to use it

Pick admin-authority when your program has:

  • A configuration or policy account that only one party should mutate (set_fee_bps, update_oracle_address, pause).
  • An emergency action that needs guarded access (recover_funds, migrate_state).
  • A handoff scenario where ownership might rotate between parties over time.

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 (the rust-rapidsnark build script downloads a prebuilt zip and unpacks it through a helper shell script) and the Python development library (a transitive dependency links against libpython through pyo3). The verification step at the end uses jq. On a fresh Ubuntu 24.04 this covers everything:

sudo apt-get update && sudo apt-get install -y curl git build-essential pkg-config libssl-dev ca-certificates unzip python3 python3-dev cmake jq

Two different floors apply here, and neither comes from admin-authority itself, which declares only rust-version = "1.88". Building your program needs rustc 1.90 or newer whenever ruint resolves freely, the floor its 1.20.0 release sets. A spel init scaffold pins ruint = "=1.17.0" in its guest manifest, so that particular floor does not apply on the scaffold route. Installing the spel CLI needs rustc 1.94.0 or newer: a transitive logos-blockchain crate uses strict_overflow_ops, which older toolchains reject as an unstable feature. The CLI pins channel = "1.94.0" in its own rust-toolchain.toml, but cargo install --git does not apply a dependency's toolchain file, so your default toolchain has to meet that floor itself. Verified on a clean Ubuntu 24.04: the CLI installs on 1.94.0, and the scaffolded program builds on the same toolchain. The lifecycle commands were verified against a live LEZ stack during the library's milestone reviews, on the same framework revision this page pins. The co-signing exchange is the one exception, see the transfer section.

Add the dependency

In your program's Cargo.toml. If you do not have a program crate yet, do Install the spel CLI and the spel init command in Annotate the module first, then come back here. For a spel init scaffold the manifest to edit is methods/guest/Cargo.toml, not the root manifest, which is a [workspace] excluding methods/guest. That guest manifest already carries its own [dependencies] table, holding spel-framework, nssa_core, risc0-zkvm, your project's _core crate, serde, borsh and the ruint = "=1.17.0" pin from the prerequisites, so merge the entries below into it rather than appending a second table, which cargo rejects outright with error: duplicate key. Scaffolded with the source flags from Annotate the module, its spel-framework and nssa_core pins already match these, so admin-authority is the only line you add:

[dependencies]
admin-authority = { git = "https://github.com/mmlado/spel-admin-authority", tag = "v0.1.2" }
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 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<AdminError> 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).

After adding the dependencies, run cargo fetch once from methods/guest, not from the project root. The scaffold's root Cargo.toml excludes methods/guest, so a root cargo fetch resolves a graph that does not contain admin-authority: it still updates the workspace's own registry and git sources and exits 0, so nothing in its output tells you the extension was skipped. 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:

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."

This build also reaches the network beyond fetching crates: a transitive build script downloads a prebuilt logos-blockchain-circuits artifact from GitHub releases. It does not honour the usual CA environment variables, so behind a TLS-inspecting proxy it fails with invalid peer certificate: UnknownIssuer partway through the build. If the download cannot reach GitHub directly, either point LBC_ROOT_DIR at a local circuits build, or pre-seed the cache using a tool that does honour your CA bundle:

mkdir -p ~/.cache/logos/blockchain
curl -fL https://github.com/logos-blockchain/logos-blockchain-circuits/releases/download/v0.5.3/logos-blockchain-circuits-v0.5.3-linux-x86_64.tar.gz \
| tar xz -C ~/.cache/logos/blockchain/

The version has to match the logos-blockchain-circuits version the pinned framework resolves to, v0.5.3 for f7aa464. The build script reuses any artifact directory it finds there and skips the download entirely.

Annotate the module

This page assumes you already have an LEZ program crate. A deployable one is a RISC Zero guest, and spel init scaffolds it at methods/guest/src/bin/<name>.rs, where <name> is the project name with hyphens replaced by underscores, so spel init my-program writes my_program.rs. That is the same layout spel generate-idl auto-detects when given no path. Pass both source flags ahead of the project name, the parser stops reading flags at the first argument that is not one, so anything after the name is dropped without a warning. With the default pins the scaffold takes logos-co/spel main, whose framework has no extension scanner and whose lee_core does not match the one admin-authority pins, and the guest then fails to compile with type errors out of the #[lez_program] expansion rather than anything naming the marker:

spel init \
--spel-git https://github.com/mmlado/spel.git \
--spel-rev f7aa464b2c6c72ef513a25ede16584bca85b722f \
my-program

If you only want to check the integration and 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.

Add #[admin_authority] to your #[lez_program] module, directly below the #[lez_program] attribute:

use spel_framework::prelude::*;

#[lez_program]
#[admin_authority]
mod my_program {
#[instruction]
pub fn create_pool(
#[account(init, pda = literal("pool"))] pool: 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:

InstructionPurpose
admin_initializeCreates the admin Config PDA and installs the caller as the first admin. Must be called once after deployment.
admin_transferReplaces the current admin with a new signer or PDA.
admin_renounceZeros the admin permanently. Terminal, no recovery path.
warning

Initialisation window. Until admin_initialize is called, the admin Config PDA does not exist. Anyone who submits the first admin_initialize becomes the admin. Send it as the very next transaction after deployment to prevent a third party from claiming the role. Bundling with the deployment itself is not possible today because a LEZ deployment transaction carries no instructions.

Gate an instruction

Add #[require_admin] to any instruction that should only succeed when the caller is the current admin:

#[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 {
// The admin check has already run.
PoolConfig { fee_bps: new_fee_bps }.write_to(&mut config)?;
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:

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 prepended to the post-states automatically, ahead of the accounts you declared, 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 only if you declare them ahead of your own parameters and in the injected order, admin_config then caller. They are then your parameters, appearing in your post-states list like any other account. Declaring them after your own parameters still compiles, but it reorders the instruction's accounts in the IDL, and that order is the transaction ABI.

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)]. Use that form whenever either name differs. The framework does reuse a declared #[account(signer)] parameter, or a PDA parameter with the matching seed, in the instruction's account list rather than injecting it twice, but the gate's prologue still refers to the accounts by their role names, so a bare #[require_admin] over a renamed parameter fails to compile with cannot find value caller in this scope pointed at the attribute.

Become the first admin

admin_initialize takes no arguments. The signing caller becomes the admin (self-election). There is no candidate argument at initialise because the LEZ duplicate-account rule rejects a transaction listing the same account twice, so a caller could never also pass itself as candidate evidence.

Every lifecycle command below reads your program's IDL from a file. Generate it once from the program you just built:

# From a `spel init` project root, drop the path entirely and the guest is auto-detected.
spel generate-idl path/to/your/program/src/main.rs > program-idl.json

To check a command without submitting it, add --dry-run=text before the -- separator. It resolves the accounts and arguments and prints the transaction it would send, which is the only way to validate an invocation before you have a node to submit to.

spel --idl program-idl.json --program <program-id> -- \
admin-initialize --caller <your-account-id>

To hand the role to a different keyholder or a PDA, initialise first and then call admin_transfer.

Transfer admin to another party

admin_transfer requires the current admin to sign. It takes an AdminCandidate describing the new admin, paired with a matching AccountWithMetadata that proves the candidate on chain.

Two candidate shapes:

// `AdminCandidate` is a re-exported alias for the shared authority type:
// `pub type AdminCandidate = AuthorityCandidate;`
pub enum AuthorityCandidate {
/// The new admin is a keyholder. Validated by checking the new account
/// co-signed the transaction.
Signer,
/// The new admin 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] },
}
spel --idl program-idl.json --program <program-id> -- \
admin-transfer \
--caller <current-admin-account-id> \
--new-account <new-admin-account-id> \
--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. The spel CLI at the pinned revision has no co-signing exchange: the command above builds and submits with the caller's signature only, and the sequencer drops the transaction unless the candidate's signature is attached. Collecting that second signature is not possible from the pinned spel. The exchange flow merged upstream after this revision (logos-co/spel#246) and arrives here when the framework pin moves.

After the transaction lands, the previous admin can no longer call gated instructions.

Use a program (PDA) as the admin

To delegate admin authority to another program, for example a multisig, use AdminCandidate::Pda with the delegating program's ID and PDA seed. Payload variants are passed to the CLI as a one-key JSON object:

spel --idl program-idl.json --program <program-id> -- \
admin-transfer \
--caller <current-admin-account-id> \
--new-account <pda-account-id> \
--candidate '{"Pda": {"program_id": "<multisig-program-id>", "seed": "<32-byte-hex-seed>"}}'

The PDA must already exist on chain as a claimed account, an unclaimed 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 accepts the PDA as the legitimate admin. No private key is needed for the PDA, authorisation comes from the seed delegation.

Embedded mode, the admin slot inside your own account

Instead of a dedicated Config PDA, the admin slot can live inside one of your program's own accounts at a byte offset. Declared once, program wide, on the marker:

use admin_authority::AdminConfig;

#[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 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("program_config"))] mut config: AccountWithMetadata,
) -> SpelResult {
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![]))
}
}

write_to is the same helper pattern from the gating section, implemented on ProgramConfig.

What changes:

  • No admin_initialize instruction. Mark your own account-creating instruction with #[admin_initialize] instead. The bootstrap is injected, the caller is installed as admin in the same transaction that creates the account, and the slot is born initialised, so the initialisation window from the warning above does not exist in embedded mode. An account created without the bootstrap is born renounced, permanently.
  • #[admin_slot] keeps the layout honest. The field marker derives an ADMIN_SLOT_OFFSET const and a layout test, and the build fails if the marker position and the offset = ... declaration ever disagree, for example after a field is added above the slot.
  • Everything retargets. Gates read the slot at the declared offset from your account, admin_transfer and admin_renounce splice only the 32 byte window and leave your neighbouring fields untouched, and the IDL shows your account wherever the dedicated PDA used to appear.
  • The offset is never in a transaction. It compiles into the program as a literal. The IDL carries no offset argument, and writing admin_config = ... or offset = ... on a gate by hand is a compile error in embedded mode.
  • One account fewer on every gated transaction, the slot travels with state you were already passing.

The embedded AdminConfig field must sit at the declared offset with only fixed-size fields before it. The library repository ships a reference sample (admin-authority-sample-embedded) with the layout, tests, and a committed dry-run walkthrough.

Renounce admin permanently

spel --idl program-idl.json --program <program-id> -- \
admin-renounce --caller <current-admin-account-id>

This writes AccountId::default() to the Config PDA. All future admin-gated instructions reject with an authorisation error. There is no recovery path, design your program so renounce is only callable when permanent loss of mutability is the intended outcome (handoff to "immutable" governance, end of life, etc.).

Verify your integration

After building your program, check that the admin instructions appear in the IDL:

# From a `spel init` project root, drop the path entirely and the guest is auto-detected.
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 pins the right revision. This check is also not a substitute for a build: generate-idl reads your source rather than compiling it, so it emits a complete IDL for a program that does not compile. Run cargo check first and treat the IDL as confirmation of the surface, not of the integration.

Expected output includes:

"admin_initialize"
"admin_transfer"
"admin_renounce"

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.
  • Cached macro expansion, run cargo clean -p <your-crate> and rebuild.

Misplacing the marker is a different failure and never reaches that error, because a marker above #[lez_program] is consumed before the framework sees it. Since nothing on this page imports admin_authority, writing it above #[lez_program] stops at name resolution instead, with cannot find attribute `admin_authority` in this scope and a note that the name is a crate rather than an attribute. Import the name and admin-authority v0.1.2 rejects the placement itself: #[admin_authority] must come after #[lez_program].

Security notes

  • Initialisation window, front-running is possible until the first admin_initialize lands. Send it immediately after deployment. Deploy-time bundling is not possible on LEZ today, a deployment transaction carries no instructions.
  • Renounce is terminal, there is no recovery. Treat it as a one-way switch.
  • PDA admins via CPI, the delegating program must declare its admin PDA in caller-pda-seeds for the gated call. LEZ verifies the seed; the admin check then trusts the propagated is_authorized.
  • Transfer history, not recorded on chain in this release. The current admin is always readable from the Config PDA; historical transfers require an off-chain indexer.

Reference

Source: github.com/mmlado/spel-admin-authority. The companion repository contains the authority lifecycle state diagram, ADRs for design decisions, and a reference sample program demonstrating end-to-end integration.