youthisguy 7fe3393179 feat: add mint authority model for LP-0013
- Added mint_authority: Option<AccountId> to TokenDefinition::Fungible
- NewFungibleDefinitionWithAuthority instruction
- SetAuthority instruction (rotate or revoke to None)
- Updated Mint with early authority guard
- Fully backwards compatible
- token_core SDK + regenerated SPEL IDL
- End-to-end demo script + tests
2026-06-19 09:53:43 +01:00
..

lez-authority

A reusable single-admin authority library for LEZ programs, satisfying RFP-001: Admin Authority Library.

Provides standardised access control for LEZ programs where privileged functions can only be called by a designated admin authority. The authority can transfer control to a new signer or permanently renounce it. There can only be one admin authority at a time.

Why

Without a shared library, every LEZ program that needs "only the admin can call this" logic ends up re-implementing it slightly differently — inconsistent error messages, inconsistent revocation semantics, inconsistent edge-case handling. lez-authority gives every program the same primitive, the same error types, and the same tested behavior.

Install

Add to your program's Cargo.toml:

[dependencies]
lez-authority = { path = "../../crates/lez-authority" }

(Adjust the relative path to wherever your program lives relative to crates/lez-authority.)

Core type

pub struct Authority(Option<AccountId>);

Authority wraps Option<AccountId> — the same representation you'd store on-chain in an account's data:

  • Some(id) — authority is active; only id may call privileged instructions.
  • None — authority has been permanently renounced. This state is terminal.

There's no separate is_revoked: bool to keep in sync — the Option is the state.

API

Method Description
Authority::new(id) Construct an active authority owned by id
Authority::renounced() Construct an already-revoked authority
Authority::from_option(opt) Build from on-chain Option<AccountId> storage
.into_option() Convert back to Option<AccountId> for on-chain storage
.is_active() / .is_renounced() Query current state
.account_id() Get the current authority's AccountId, if active
.require(is_authorized) Gate a privileged call. Errors if renounced or unauthorized
.rotate(new_id, is_authorized) Transfer authority to new_id. Errors if renounced or unauthorized
.revoke(is_authorized) Permanently renounce. Errors if already renounced or unauthorized

Errors

pub enum AuthorityError {
    Unauthorized,  // caller is not the current authority
    Renounced,     // authority has been permanently revoked
}

Both variants implement Display, so panic!("{e}") in a guest program produces a clear, deterministic message that the sequencer surfaces as the transaction's rejection reason.

Usage example

This is the actual pattern used by the LEZ token program to gate minting:

use lez_authority::Authority;

pub fn mint(
    definition_account: AccountWithMetadata,
    /* ... */
) -> Vec<AccountPostState> {
    let mut definition = TokenDefinition::try_from(&definition_account.account.data)
        .expect("Definition account must be valid");

    match &definition {
        TokenDefinition::Fungible { mint_authority, .. } => {
            let auth = Authority::from_option(*mint_authority);
            auth.require(definition_account.is_authorized)
                .unwrap_or_else(|e| panic!("{e}"));
        }
        TokenDefinition::NonFungible { .. } => {
            panic!("Cannot mint additional supply for Non-Fungible Tokens");
        }
    }

    // ... proceed with minting
}

And gating authority rotation/revocation:

use lez_authority::Authority;

pub fn set_authority(
    definition_account: AccountWithMetadata,
    new_authority: Option<AccountId>,
) -> Vec<AccountPostState> {
    let mut definition = TokenDefinition::try_from(&definition_account.account.data)
        .expect("Definition account must be valid");

    match &mut definition {
        TokenDefinition::Fungible { mint_authority, .. } => {
            let mut auth = Authority::from_option(*mint_authority);

            match new_authority {
                Some(new_id) => auth
                    .rotate(new_id, definition_account.is_authorized)
                    .unwrap_or_else(|e| panic!("{e}")),
                None => auth
                    .revoke(definition_account.is_authorized)
                    .unwrap_or_else(|e| panic!("{e}")),
            }

            *mint_authority = auth.into_option();
        }
        TokenDefinition::NonFungible { .. } => {
            panic!("Cannot set mint authority on a Non-Fungible Token definition");
        }
    }

    // ... write post-state
}

The require_authority! macro

For guest programs that prefer macro-style gating over manual match/unwrap_or_else:

use lez_authority::{Authority, require_authority};

let auth = Authority::from_option(stored_authority);
require_authority!(auth, is_authorized);
// continues only if authorized; panics with a clear message otherwise

Reference integration

The LEZ token program (programs/token/src/mint.rs and programs/token/src/set_authority.rs) is the reference consumer of this library. Read those two files for a complete, tested, production integration — including how the on-chain Option<AccountId> field round-trips through Authority::from_option / .into_option().

Design notes

Why a single Authority type instead of free functions? Bundling the Option<AccountId> state with its operations means rotate/revoke can enforce their own preconditions (require() runs first) without the caller having to remember to check first. Misuse is harder.

Why does require() check "renounced" before "unauthorized"? If authority has been renounced, there is no valid signer who could possibly satisfy the check — Renounced is the more informative error regardless of the is_authorized flag's value. This ordering is centralized here so every consumer gets it for free, rather than each program deciding independently.

Why is revocation irreversible? This mirrors Solana's SPL Token set_authority(None) semantics. A revoked authority cannot be "re-granted" by anyone, including the original holder — this is what makes "fixed supply" or "config locked forever" a credible, verifiable claim rather than a soft convention.

Why no multisig support? Out of scope for this library — Authority models exactly one signer. Programs needing shared governance over a privileged action should have a multisig program be the authority (i.e., the AccountId held by Authority is itself a multisig program's PDA), rather than lez-authority reimplementing multisig internally.

Overhead

Authority is a zero-cost wrapper around Option<AccountId> — identical in memory layout to the field it wraps. There are no additional accounts, no additional instruction fields, and no serialization overhead introduced by routing a check through this library instead of writing the equivalent match inline.

Tests

cargo test -p lez-authority

16 unit tests cover every method in isolation: construction, state queries, require/rotate/revoke success and failure paths, and a full lifecycle test (init → rotate → revoke → confirm no further action possible).