mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-03 21:49:28 +00:00
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
This commit is contained in:
parent
e8fe634a2c
commit
7fe3393179
@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
description: Deploy a LEZ program to the sequencer. Use when the user asks to deploy, ship, or publish a program (e.g. "deploy the token program", "ship amm to the sequencer").
|
|
||||||
---
|
|
||||||
|
|
||||||
# deploy-program
|
|
||||||
|
|
||||||
Deploying a LEZ program is always a two-step process: compile first, then deploy. Never deploy
|
|
||||||
without rebuilding first — a stale binary deploys silently but won't reflect recent code changes.
|
|
||||||
|
|
||||||
The program name corresponds to a top-level workspace directory. If none is specified, discover
|
|
||||||
available programs by looking for `<name>/methods/guest/Cargo.toml` and ask the user to pick one.
|
|
||||||
|
|
||||||
After deploying, confirm success by inspecting the binary and reporting the ProgramId to the user.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Docker must be running.** `cargo risczero build` cross-compiles via Docker. Fail fast if not.
|
|
||||||
- **The output binary path follows a fixed convention** — derive it from the program name, don't guess.
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
description: Get the program ID (Image ID) for a LEZ program. Use when the user asks for a program's ID, image ID, or program address (e.g. "what's the token program id", "get the amm program id").
|
|
||||||
---
|
|
||||||
|
|
||||||
# program-id
|
|
||||||
|
|
||||||
The program ID is the RISC Zero Image ID derived from the compiled guest ELF binary.
|
|
||||||
|
|
||||||
The program name corresponds to a top-level workspace directory. If none is specified, discover
|
|
||||||
available programs by looking for `<name>/methods/guest/Cargo.toml` and ask the user to pick one.
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
1. **Check if the binary exists** at `<name>/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/<name>.bin`.
|
|
||||||
2. **If missing, build it first** using `cargo risczero build --manifest-path <name>/methods/guest/Cargo.toml`.
|
|
||||||
- Docker must be running for this step. Fail fast if not.
|
|
||||||
3. **Inspect the binary** with `spel-cli inspect <path-to-binary>` and report the program ID to the user.
|
|
||||||
434
Cargo.lock
generated
434
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@ members = [
|
|||||||
"programs/stablecoin/methods",
|
"programs/stablecoin/methods",
|
||||||
"programs/integration_tests",
|
"programs/integration_tests",
|
||||||
"tools/idl-gen",
|
"tools/idl-gen",
|
||||||
|
"crates/lez-authority",
|
||||||
]
|
]
|
||||||
exclude = [
|
exclude = [
|
||||||
"programs/token/methods/guest",
|
"programs/token/methods/guest",
|
||||||
|
|||||||
209
README.md
209
README.md
@ -1,150 +1,155 @@
|
|||||||
# lez-programs
|
# LP-0013: Token Program — Mint Authority
|
||||||
|
|
||||||
Essential programs for the **Logos Execution Zone (LEZ)** — a zkVM-based execution environment built on [RISC Zero](https://risczero.com/). Programs run inside the RISC Zero zkVM (`riscv32im-risc0-zkvm-elf` target) and interact with the LEZ runtime via the `nssa_core` library.
|
This fork of [logos-blockchain/lez-programs](https://github.com/logos-blockchain/lez-programs) adds a mint authority model to the LEZ token program, enabling variable supply tokens, permissioned issuance, and the standard "revoke to fix supply" pattern expected by wallets and DeFi protocols.
|
||||||
|
|
||||||
## Programs
|
For the LP-0013 contribution, what changed, and how to run it — see below.
|
||||||
|
For the wallet CLI additions (two new commands: `token new-with-authority` and `token set-authority`) — see the supporting fork: [youthisguy/logos-execution-zone](https://github.com/youthisguy/logos-execution-zone).
|
||||||
|
Everything else is the upstream lez-programs codebase.
|
||||||
|
|
||||||
| Program | Description |
|
---
|
||||||
|
|
||||||
|
## What was added
|
||||||
|
|
||||||
|
- `mint_authority: Option<AccountId>` field on `TokenDefinition::Fungible`
|
||||||
|
- `NewFungibleDefinitionWithAuthority` instruction — create a token with a mint authority at initialization
|
||||||
|
- `SetAuthority` instruction — rotate authority to a new account, or revoke it permanently by passing `None`
|
||||||
|
- Updated `Mint` instruction — enforces the authority check before any state write
|
||||||
|
- Fully backwards compatible — the existing `NewFungibleDefinition` instruction is unchanged
|
||||||
|
|
||||||
|
The design follows Solana's SPL Token: a single `Option<AccountId>` encodes both who the authority is and whether minting is possible. `None` is self-describing — no authority, no minting, ever.
|
||||||
|
|
||||||
|
## Admin Authority Library (RFP-001)
|
||||||
|
|
||||||
|
The mint authority logic is implemented as a standalone, reusable crate — [`crates/lez-authority`](crates/lez-authority) - This satisfies [RFP-001: Admin Authority Library](https://github.com/logos-co/rfp/blob/master/RFPs/RFP-001-admin-authority-lib.md), which calls for standardised access control that any LEZ program can adopt.
|
||||||
|
|
||||||
|
`lez-authority` provides:
|
||||||
|
- `Authority` — wraps `Option<AccountId>`; `Some(id)` is an active authority, `None` is permanently renounced
|
||||||
|
- `Authority::rotate()` — transfer authority to a new signer (requires current authorization)
|
||||||
|
- `Authority::revoke()` — permanently renounce authority (irreversible)
|
||||||
|
- `Authority::require()` — gate a privileged instruction; returns `AuthorityError::Unauthorized` or `AuthorityError::Renounced`
|
||||||
|
- `require_authority!` macro — panics with a clear message in LEZ guest programs
|
||||||
|
|
||||||
|
The token program is the first consumer: `mint.rs` and `set_authority.rs` both call into `lez-authority` instead of implementing authorization checks inline. See [`crates/lez-authority/README.md`](crates/lez-authority/README.md) for integration instructions and a usage example.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
|
||||||
|
| Repo | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **token** | Fungible and non-fungible token program — create definitions, mint/burn tokens, transfer, initialize accounts, print NFTs |
|
| [youthisguy/lez-programs](https://github.com/youthisguy/lez-programs) ← **this repo** | Token program changes, `token_core` SDK, integration tests, demo script |
|
||||||
| **amm** | Constant-product AMM — add/remove liquidity and swap via chained calls to the token program |
|
| [youthisguy/logos-execution-zone](https://github.com/youthisguy/logos-execution-zone) | Sequencer, wallet CLI (`token new-with-authority`, `token set-authority`) |
|
||||||
| **ata** | Associated Token Account program — derives and initializes deterministic token holding accounts for a given owner and token definition |
|
|
||||||
| **stablecoin** | Collateral-backed position program — open collateral positions as a foundation for stablecoin debt issuance |
|
|
||||||
| **twap_oracle** | TWAP oracle — provides canonical on-chain price accounts consumed by other programs (e.g. stablecoin) |
|
|
||||||
|
|
||||||
## Apps
|
---
|
||||||
|
|
||||||
| App | Description |
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **amm** | QML-based UI for interacting with the AMM program |
|
| [programs/token/README.md](programs/token/README.md) | End-to-end usage: deploy steps, program addresses, CLI instructions for minting, rotating, and revoking authority |
|
||||||
|
| [docs/authority-model.md](docs/authority-model.md) | Full design spec: data model, instruction semantics, authority lifecycle diagram, atomicity proof, error codes, authorization model, backwards compatibility, threat model |
|
||||||
|
| [artifacts/token-idl.json](artifacts/token-idl.json) | SPEL-generated IDL for the updated token program (regenerate with `cargo run -p idl-gen`) |
|
||||||
|
|
||||||
## Running Apps
|
---
|
||||||
|
|
||||||
Apps live under `apps/` and are standalone UI applications. Each app has its own `README.md` with full details.
|
## Example Integrations
|
||||||
|
|
||||||
Apps use [Nix](https://nixos.org/) flakes. Enable flakes if you haven't already:
|
Two example Rust programs are in [`examples/program_deployment/src/bin/`](examples/program_deployment/src/bin/):
|
||||||
|
|
||||||
```bash
|
| Example | Description |
|
||||||
mkdir -p ~/.config/nix && echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|---|---|
|
||||||
```
|
| [`run_new_token_with_authority.rs`](examples/program_deployment/src/bin/run_new_token_with_authority.rs) | Variable supply token — creates a token with an active mint authority, mints additional supply, then rotates the authority |
|
||||||
|
| [`run_new_fixed_supply_token.rs`](examples/program_deployment/src/bin/run_new_fixed_supply_token.rs) | Fixed supply token — creates a token with `mint_authority: None` at initialization; no revocation step needed |
|
||||||
|
|
||||||
### Example (`apps/amm`)
|
---
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/amm
|
|
||||||
|
|
||||||
# Run the app
|
|
||||||
nix run .
|
|
||||||
|
|
||||||
# Update pinned dependencies
|
|
||||||
nix flake update
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- **Rust** — install via [rustup](https://rustup.rs/). The pinned toolchain version is set in `rust-toolchain.toml`.
|
|
||||||
- **RISC Zero toolchain** — required to build guest ZK binaries:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo install cargo-risczero
|
|
||||||
cargo risczero install
|
|
||||||
```
|
|
||||||
- **SPEL toolchain** — provides `spel` and `wallet` CLI tools. Install from [logos-co/spel](https://github.com/logos-co/spel).
|
|
||||||
- **LEZ** — provides `wallet` CLI. Install from [logos-blockchain/logos-execution-zone](https://github.com/logos-blockchain/logos-execution-zone)
|
|
||||||
|
|
||||||
## Build & Test
|
## Build & Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Lint the entire workspace (skips expensive guest ZK builds)
|
git clone https://github.com/youthisguy/lez-programs.git
|
||||||
make clippy
|
cd lez-programs
|
||||||
|
|
||||||
# Format check
|
# All tests (skips ZK proof generation)
|
||||||
make fmt
|
RISC0_DEV_MODE=1 cargo test --release
|
||||||
|
|
||||||
# Run unit tests for all programs (no zkVM, no ZK proof generation)
|
# Token unit tests only
|
||||||
RISC0_DEV_MODE=1 cargo test -p token_program -p amm_program -p ata_program -p stablecoin_program -p twap_oracle_program
|
RISC0_DEV_MODE=1 cargo test --release -p token_program
|
||||||
|
|
||||||
# Run integration tests (dev mode skips ZK proof generation)
|
# Token integration tests only
|
||||||
RISC0_DEV_MODE=1 cargo test -p integration_tests
|
RISC0_DEV_MODE=1 cargo test --release -p integration_tests --test token
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
make test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Integration tests live in `programs/integration_tests/tests/` and cover `token`, `amm`, and `ata` programs end-to-end through the zkVM using `RISC0_DEV_MODE=1` to skip proof generation. Each test file corresponds to a program:
|
CI status: [](https://github.com/youthisguy/lez-programs/actions)
|
||||||
|
|
||||||
- `programs/integration_tests/tests/token.rs`
|
---
|
||||||
- `programs/integration_tests/tests/amm.rs`
|
|
||||||
- `programs/integration_tests/tests/ata.rs`
|
|
||||||
|
|
||||||
`stablecoin` and `twap_oracle` are tested via their own unit tests (`cargo test -p stablecoin_program -p twap_oracle_program`).
|
## End-to-End Demo
|
||||||
|
|
||||||
## Compile Guest Binaries
|
The demo script runs the full authority lifecycle against a real local sequencer with `RISC0_DEV_MODE=0`
|
||||||
|
|
||||||
The guest binaries are compiled to the `riscv32im-risc0-zkvm-elf` target. This requires the RISC Zero toolchain.
|
### Prerequisites
|
||||||
|
|
||||||
|
Clone and build the supporting repo first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo risczero build --manifest-path <PROGRAM>/methods/guest/Cargo.toml
|
git clone https://github.com/youthisguy/logos-execution-zone.git
|
||||||
|
cd logos-execution-zone
|
||||||
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
Binaries are output to:
|
Start all three services in separate terminals:
|
||||||
|
|
||||||
```
|
|
||||||
<PROGRAM>/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/<PROGRAM>.bin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy a program binary to the sequencer
|
# Terminal 1 — Bedrock
|
||||||
wallet deploy-program <path-to-binary>
|
cd logos-execution-zone/bedrock && docker compose up
|
||||||
|
|
||||||
# Example
|
# Terminal 2 — Sequencer (after bedrock shows "proposed block")
|
||||||
wallet deploy-program programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin
|
cd logos-execution-zone/lez/sequencer/service
|
||||||
wallet deploy-program programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin
|
RUST_LOG=info RISC0_DEV_MODE=0 cargo run --release -p sequencer_service configs/debug/sequencer_config.json
|
||||||
wallet deploy-program programs/ata/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/ata.bin
|
|
||||||
wallet deploy-program programs/stablecoin/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/stablecoin.bin
|
# Terminal 3 — Indexer
|
||||||
wallet deploy-program programs/twap_oracle/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/twap_oracle.bin
|
cd logos-execution-zone/lez/indexer/service
|
||||||
|
RUST_LOG=info cargo run --release -p indexer_service configs/indexer_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
To inspect the `ProgramId` of a built binary:
|
### Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spel inspect <path-to-binary>
|
RISC0_DEV_MODE=0 \
|
||||||
|
WALLET_BIN=/path/to/logos-execution-zone/target/release/wallet \
|
||||||
|
LEZ_WALLET_HOME_DIR=/path/to/logos-execution-zone/lez/wallet/configs/debug \
|
||||||
|
bash scripts/demo.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Interacting with Programs via `spel`
|
See [`scripts/demo.sh`](scripts/demo.sh) for what each step does. Execution times appear in the sequencer logs as `execution time:` lines.
|
||||||
|
|
||||||
### Generate an IDL
|
---
|
||||||
|
|
||||||
The IDL describes the program's instructions and can be used to interact with a deployed program.
|
## Compute Unit Costs
|
||||||
|
|
||||||
**Using the `idl-gen` crate** (no external toolchain required — this is what CI uses):
|
Measured on local LEZ sequencer (standalone mode) with `RISC0_DEV_MODE=0`. Reproducible via `scripts/demo.sh` as above.
|
||||||
|
|
||||||
```bash
|
| Operation | Tx Hash | Block | Execution Time |
|
||||||
make idl
|
|---|---|---|---|
|
||||||
```
|
| `NewFungibleDefinitionWithAuthority` | `14197f9113ff000e81b7545c671942b286ef19bae7122ba280a0a620b8e01ca1` | 410 | 15.92ms |
|
||||||
|
| `Mint` (authority active) | `99f00dbe40600d0c8bb745b74980c2241f1e7a6daa1291f5cef6b9ea27c82bd9` | 411 | 19.29ms |
|
||||||
|
| `SetAuthority` (rotate) | `d865e26dfb5f82a5528aa9a0882307a73b00ffc4fa7825f0e7b5d0888d5c87fc` | 414 | 13.40ms |
|
||||||
|
| `SetAuthority` (revoke to None) | `9408ef7ffd3efdbafbe2dd5bf243da32edd1a4d52f9709b5cfc92cb696b8956e` | 415 | 15.74ms |
|
||||||
|
| `Mint` (rejected — authority revoked) | `5228cc62094a91e479b86a3aee067809f18674465ac72d8623d1ed770ab496de` | 416 | 9.84ms |
|
||||||
|
|
||||||
**Using the `spel` CLI** (requires the SPEL toolchain):
|
Rejected operations cost ~38% less than successful ones — execution halts at the authority guard before any account writes, confirming rejection is via the correct code path.
|
||||||
|
|
||||||
```bash
|
> **Note:** These measurements use local sequencer executor timing with real proof generation (`RISC0_DEV_MODE=0`). Testnet CU measurements will be added once the testnet exposes this data.
|
||||||
spel generate-idl programs/token/methods/guest/src/bin/token.rs > artifacts/token-idl.json
|
|
||||||
spel generate-idl programs/amm/methods/guest/src/bin/amm.rs > artifacts/amm-idl.json
|
|
||||||
spel generate-idl programs/ata/methods/guest/src/bin/ata.rs > artifacts/ata-idl.json
|
|
||||||
spel generate-idl programs/stablecoin/methods/guest/src/bin/stablecoin.rs > artifacts/stablecoin-idl.json
|
|
||||||
spel generate-idl programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs > artifacts/twap_oracle-idl.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Generated IDL files are committed under `artifacts/`. CI will fail if a program's IDL is missing or out of date.
|
---
|
||||||
|
|
||||||
### Invoke Instructions
|
## Video Demo
|
||||||
|
|
||||||
Use `spel --idl <IDL> <INSTRUCTION> [ARGS...]` to call a deployed program instruction:
|
Narrated walkthrough showing terminal output with `RISC0_DEV_MODE=0` active during proof generation:
|
||||||
|
[https://youtu.be/mbNpOoOs7T4](https://youtu.be/mbNpOoOs7T4)
|
||||||
|
|
||||||
```bash
|
---
|
||||||
spel --idl artifacts/token-idl.json <instruction> [args...]
|
|
||||||
spel --idl artifacts/amm-idl.json <instruction> [args...]
|
## License
|
||||||
spel --idl artifacts/ata-idl.json <instruction> [args...]
|
|
||||||
spel --idl artifacts/stablecoin-idl.json <instruction> [args...]
|
[MIT](LICENSE)
|
||||||
spel --idl artifacts/twap_oracle-idl.json <instruction> [args...]
|
|
||||||
```
|
|
||||||
@ -400,6 +400,12 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"option": "account_id"
|
"option": "account_id"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -120,6 +120,12 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"option": "account_id"
|
"option": "account_id"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -160,6 +160,12 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"option": "account_id"
|
"option": "account_id"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -342,18 +348,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"types": [
|
"types": [
|
||||||
{
|
|
||||||
"name": "MetadataStandard",
|
|
||||||
"kind": "enum",
|
|
||||||
"variants": [
|
|
||||||
{
|
|
||||||
"name": "Simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Expanded"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "ObservationEntry",
|
"name": "ObservationEntry",
|
||||||
"kind": "struct",
|
"kind": "struct",
|
||||||
@ -367,6 +361,18 @@
|
|||||||
"type": "i64"
|
"type": "i64"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MetadataStandard",
|
||||||
|
"kind": "enum",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "Simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Expanded"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"instruction_type": "stablecoin_core::Instruction"
|
"instruction_type": "stablecoin_core::Instruction"
|
||||||
|
|||||||
@ -52,6 +52,58 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "new_fungible_definition_with_authority",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "holding_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_supply",
|
||||||
|
"type": "u128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_authority",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "new_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "new_definition_with_metadata",
|
"name": "new_definition_with_metadata",
|
||||||
"accounts": [
|
"accounts": [
|
||||||
@ -194,6 +246,12 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"option": "account_id"
|
"option": "account_id"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
204
artifacts/token.idl.json
Normal file
204
artifacts/token.idl.json
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.0",
|
||||||
|
"name": "token",
|
||||||
|
"instructions": [
|
||||||
|
{
|
||||||
|
"name": "transfer",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "sender",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recipient",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "amount_to_transfer",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_fungible_definition",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "holding_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_supply",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_fungible_definition_with_authority",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "holding_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_supply",
|
||||||
|
"type": "u128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_authority",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "new_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_definition_with_metadata",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "holding_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "new_definition",
|
||||||
|
"type": {
|
||||||
|
"defined": "NewTokenDefinition"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata",
|
||||||
|
"type": {
|
||||||
|
"defined": "Box"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "initialize_account",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "account_to_initialize",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "burn",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_holding_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "amount_to_burn",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_holding_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "amount_to_mint",
|
||||||
|
"type": "u128"
|
||||||
11
crates/lez-authority/Cargo.toml
Normal file
11
crates/lez-authority/Cargo.toml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "lez-authority"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Reusable single-admin authority library for LEZ programs (RFP-001)"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
165
crates/lez-authority/README.md
Normal file
165
crates/lez-authority/README.md
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# lez-authority
|
||||||
|
|
||||||
|
A reusable single-admin authority library for LEZ programs, satisfying [RFP-001: Admin Authority Library](https://github.com/logos-co/rfp/blob/master/RFPs/RFP-001-admin-authority-lib.md).
|
||||||
|
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
lez-authority = { path = "../../crates/lez-authority" }
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adjust the relative path to wherever your program lives relative to `crates/lez-authority`.)
|
||||||
|
|
||||||
|
## Core type
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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).
|
||||||
314
crates/lez-authority/src/lib.rs
Normal file
314
crates/lez-authority/src/lib.rs
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
//! # lez-authority
|
||||||
|
//!
|
||||||
|
//! A reusable single-admin authority library for LEZ programs, satisfying RFP-001.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! Add to your program's `Cargo.toml`:
|
||||||
|
//! ```toml
|
||||||
|
//! [dependencies]
|
||||||
|
//! lez-authority = { path = "../../crates/lez-authority" }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Gate a privileged instruction:
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use lez_authority::{Authority, AuthorityError};
|
||||||
|
//!
|
||||||
|
//! pub fn my_privileged_instruction(
|
||||||
|
//! is_authorized: bool,
|
||||||
|
//! current_authority: Option<AccountId>,
|
||||||
|
//! ) -> Result<(), AuthorityError> {
|
||||||
|
//! let auth = Authority::from_option(current_authority);
|
||||||
|
//! auth.require(is_authorized)?;
|
||||||
|
//! // ... privileged logic
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use nssa_core::account::AccountId;
|
||||||
|
|
||||||
|
/// Single-admin authority state.
|
||||||
|
///
|
||||||
|
/// Wraps `Option<AccountId>`:
|
||||||
|
/// - `Some(id)` — authority is active; only `id` may call privileged instructions.
|
||||||
|
/// - `None` — authority has been permanently renounced; no further privileged calls
|
||||||
|
/// are possible. This state is terminal and cannot be reversed.
|
||||||
|
///
|
||||||
|
/// There can only be one admin authority at a time.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Authority(Option<AccountId>);
|
||||||
|
|
||||||
|
impl Authority {
|
||||||
|
/// Create an active authority with the given account ID.
|
||||||
|
pub fn new(id: AccountId) -> Self {
|
||||||
|
Self(Some(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a permanently renounced authority.
|
||||||
|
/// This is equivalent to calling `revoke()` — the state is terminal.
|
||||||
|
pub fn renounced() -> Self {
|
||||||
|
Self(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct from an `Option<AccountId>` as stored on-chain.
|
||||||
|
pub fn from_option(opt: Option<AccountId>) -> Self {
|
||||||
|
Self(opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert back to `Option<AccountId>` for on-chain storage.
|
||||||
|
pub fn into_option(self) -> Option<AccountId> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the authority has been permanently renounced.
|
||||||
|
pub fn is_renounced(&self) -> bool {
|
||||||
|
self.0.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the authority is still active.
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.0.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current authority account ID, if active.
|
||||||
|
pub fn account_id(&self) -> Option<AccountId> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that the caller is authorized to perform a privileged action.
|
||||||
|
///
|
||||||
|
/// The `is_authorized` flag is set by the LEZ protocol when the transaction
|
||||||
|
/// includes a valid signature from the authority account's keypair.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AuthorityError::Renounced`] if the authority has been permanently revoked.
|
||||||
|
/// - [`AuthorityError::Unauthorized`] if `is_authorized` is `false`.
|
||||||
|
pub fn require(&self, is_authorized: bool) -> Result<(), AuthorityError> {
|
||||||
|
if self.is_renounced() {
|
||||||
|
return Err(AuthorityError::Renounced);
|
||||||
|
}
|
||||||
|
if !is_authorized {
|
||||||
|
return Err(AuthorityError::Unauthorized);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer authority to a new account ID.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AuthorityError::Renounced`] if the authority has already been renounced.
|
||||||
|
/// - [`AuthorityError::Unauthorized`] if `is_authorized` is `false`.
|
||||||
|
pub fn rotate(
|
||||||
|
&mut self,
|
||||||
|
new_authority: AccountId,
|
||||||
|
is_authorized: bool,
|
||||||
|
) -> Result<(), AuthorityError> {
|
||||||
|
self.require(is_authorized)?;
|
||||||
|
self.0 = Some(new_authority);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently renounce the authority.
|
||||||
|
///
|
||||||
|
/// After calling this, [`is_renounced`](Self::is_renounced) returns `true`
|
||||||
|
/// and no further privileged calls are possible. This operation is irreversible.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AuthorityError::Renounced`] if the authority has already been renounced.
|
||||||
|
/// - [`AuthorityError::Unauthorized`] if `is_authorized` is `false`.
|
||||||
|
pub fn revoke(&mut self, is_authorized: bool) -> Result<(), AuthorityError> {
|
||||||
|
self.require(is_authorized)?;
|
||||||
|
self.0 = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors produced by authority checks.
|
||||||
|
///
|
||||||
|
/// In LEZ guest programs, these are surfaced as panics since the prover
|
||||||
|
/// catches panics and rejects the transaction.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AuthorityError {
|
||||||
|
/// The caller is not the current authority.
|
||||||
|
Unauthorized,
|
||||||
|
/// The authority has been permanently renounced.
|
||||||
|
/// No privileged actions are possible in this state.
|
||||||
|
Renounced,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for AuthorityError {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AuthorityError::Unauthorized => {
|
||||||
|
write!(f, "Unauthorized: caller is not the current authority")
|
||||||
|
}
|
||||||
|
AuthorityError::Renounced => {
|
||||||
|
write!(f, "Renounced: authority has been permanently revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience macro to assert authority in LEZ guest programs.
|
||||||
|
///
|
||||||
|
/// Panics with a clear message if the authority check fails,
|
||||||
|
/// following the LEZ guest program convention.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// require_authority!(authority, is_authorized);
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! require_authority {
|
||||||
|
($authority:expr, $is_authorized:expr) => {
|
||||||
|
match $authority.require($is_authorized) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(lez_authority::AuthorityError::Renounced) => {
|
||||||
|
panic!("AuthorityError::Renounced: authority has been permanently revoked")
|
||||||
|
}
|
||||||
|
Err(lez_authority::AuthorityError::Unauthorized) => {
|
||||||
|
panic!("AuthorityError::Unauthorized: caller is not the current authority")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_account_id(byte: u8) -> AccountId {
|
||||||
|
AccountId::new([byte; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_authority_is_active() {
|
||||||
|
let auth = Authority::new(test_account_id(1));
|
||||||
|
assert!(auth.is_active());
|
||||||
|
assert!(!auth.is_renounced());
|
||||||
|
assert_eq!(auth.account_id(), Some(test_account_id(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_renounced_authority_is_inactive() {
|
||||||
|
let auth = Authority::renounced();
|
||||||
|
assert!(!auth.is_active());
|
||||||
|
assert!(auth.is_renounced());
|
||||||
|
assert_eq!(auth.account_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_succeeds_when_authorized() {
|
||||||
|
let auth = Authority::new(test_account_id(1));
|
||||||
|
assert!(auth.require(true).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_fails_when_unauthorized() {
|
||||||
|
let auth = Authority::new(test_account_id(1));
|
||||||
|
assert_eq!(auth.require(false), Err(AuthorityError::Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_fails_when_renounced() {
|
||||||
|
let auth = Authority::renounced();
|
||||||
|
assert_eq!(auth.require(true), Err(AuthorityError::Renounced));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rotate_transfers_authority() {
|
||||||
|
let mut auth = Authority::new(test_account_id(1));
|
||||||
|
assert!(auth.rotate(test_account_id(2), true).is_ok());
|
||||||
|
assert_eq!(auth.account_id(), Some(test_account_id(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rotate_fails_when_unauthorized() {
|
||||||
|
let mut auth = Authority::new(test_account_id(1));
|
||||||
|
assert_eq!(
|
||||||
|
auth.rotate(test_account_id(2), false),
|
||||||
|
Err(AuthorityError::Unauthorized)
|
||||||
|
);
|
||||||
|
// Authority unchanged
|
||||||
|
assert_eq!(auth.account_id(), Some(test_account_id(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rotate_fails_when_renounced() {
|
||||||
|
let mut auth = Authority::renounced();
|
||||||
|
assert_eq!(
|
||||||
|
auth.rotate(test_account_id(1), true),
|
||||||
|
Err(AuthorityError::Renounced)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_revoke_renounces_authority() {
|
||||||
|
let mut auth = Authority::new(test_account_id(1));
|
||||||
|
assert!(auth.revoke(true).is_ok());
|
||||||
|
assert!(auth.is_renounced());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_revoke_fails_when_unauthorized() {
|
||||||
|
let mut auth = Authority::new(test_account_id(1));
|
||||||
|
assert_eq!(auth.revoke(false), Err(AuthorityError::Unauthorized));
|
||||||
|
// Authority still active
|
||||||
|
assert!(auth.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_revoke_fails_when_already_renounced() {
|
||||||
|
let mut auth = Authority::renounced();
|
||||||
|
assert_eq!(auth.revoke(true), Err(AuthorityError::Renounced));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_option_some() {
|
||||||
|
let auth = Authority::from_option(Some(test_account_id(5)));
|
||||||
|
assert!(auth.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_option_none() {
|
||||||
|
let auth = Authority::from_option(None);
|
||||||
|
assert!(auth.is_renounced());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_into_option_active() {
|
||||||
|
let auth = Authority::new(test_account_id(3));
|
||||||
|
assert_eq!(auth.into_option(), Some(test_account_id(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_into_option_renounced() {
|
||||||
|
let auth = Authority::renounced();
|
||||||
|
assert_eq!(auth.into_option(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_lifecycle() {
|
||||||
|
// Init with authority A
|
||||||
|
let mut auth = Authority::new(test_account_id(1));
|
||||||
|
assert!(auth.require(true).is_ok());
|
||||||
|
|
||||||
|
// Rotate to B
|
||||||
|
auth.rotate(test_account_id(2), true).unwrap();
|
||||||
|
assert_eq!(auth.account_id(), Some(test_account_id(2)));
|
||||||
|
|
||||||
|
// Old authority no longer valid (simulated by is_authorized=false)
|
||||||
|
assert_eq!(auth.require(false), Err(AuthorityError::Unauthorized));
|
||||||
|
|
||||||
|
// New authority revokes
|
||||||
|
auth.revoke(true).unwrap();
|
||||||
|
assert!(auth.is_renounced());
|
||||||
|
|
||||||
|
// No further actions possible
|
||||||
|
assert_eq!(auth.require(true), Err(AuthorityError::Renounced));
|
||||||
|
}
|
||||||
|
}
|
||||||
272
docs/authority-model.md
Normal file
272
docs/authority-model.md
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
# Token Mint Authority — Design Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document specifies the mint authority model added to the LEZ token program as part of LP-0013. It covers the data model, instruction semantics, authority lifecycle, atomicity guarantees, error codes, and the moderator trust model.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### `TokenDefinition::Fungible`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
TokenDefinition::Fungible {
|
||||||
|
name: String,
|
||||||
|
total_supply: u128,
|
||||||
|
metadata_id: Option<AccountId>,
|
||||||
|
mint_authority: Option<AccountId>, // ← new field
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`mint_authority: Option<AccountId>` encodes two things in one field:
|
||||||
|
|
||||||
|
- `Some(account_id)` — that account is the current mint authority. Only a transaction signed by that account's key may mint additional tokens.
|
||||||
|
- `None` — the supply is permanently fixed. No further minting is possible, ever.
|
||||||
|
|
||||||
|
Using a single `Option` instead of a separate `is_fixed_supply: bool` eliminates the risk of inconsistent state (e.g. `is_fixed_supply: false` with `mint_authority: None`). This is the same design used by Solana's SPL Token program.
|
||||||
|
|
||||||
|
## Admin Authority Library (RFP-001)
|
||||||
|
|
||||||
|
Authority enforcement is implemented in a standalone crate, [`crates/lez-authority`](../crates/lez-authority), rather than inline in the token program. This satisfies [RFP-001: Admin Authority Library](https://github.com/logos-co/rfp/blob/master/RFPs/RFP-001-admin-authority-lib.md).
|
||||||
|
|
||||||
|
### `Authority`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Authority(Option<AccountId>);
|
||||||
|
```
|
||||||
|
|
||||||
|
Wraps the same `Option<AccountId>` representation used in `TokenDefinition::Fungible.mint_authority` — there is no translation layer between on-chain storage and the library's in-memory type.
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Authority::new(id)` | Construct an active authority |
|
||||||
|
| `Authority::renounced()` | Construct a permanently revoked authority |
|
||||||
|
| `Authority::from_option(opt)` / `.into_option()` | Convert to/from on-chain storage representation |
|
||||||
|
| `.is_active()` / `.is_renounced()` | Query current state |
|
||||||
|
| `.require(is_authorized)` | Gate a privileged instruction. Returns `Err(AuthorityError::Renounced)` if revoked, `Err(AuthorityError::Unauthorized)` if `is_authorized` is `false`, else `Ok(())` |
|
||||||
|
| `.rotate(new_id, is_authorized)` | Transfer authority — internally calls `.require()` first |
|
||||||
|
| `.revoke(is_authorized)` | Permanently renounce — internally calls `.require()` first |
|
||||||
|
|
||||||
|
### `AuthorityError`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum AuthorityError {
|
||||||
|
Unauthorized,
|
||||||
|
Renounced,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both variants implement `Display`, producing the exact strings that surface as guest panics: `"Unauthorized: caller is not the current authority"` and `"Renounced: authority has been permanently revoked"`.
|
||||||
|
|
||||||
|
### Token program integration
|
||||||
|
|
||||||
|
`mint.rs` and `set_authority.rs` are the first consumers:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// mint.rs
|
||||||
|
let auth = Authority::from_option(*mint_authority);
|
||||||
|
auth.require(definition_account.is_authorized)
|
||||||
|
.unwrap_or_else(|e| panic!("{e}"));
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// set_authority.rs
|
||||||
|
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();
|
||||||
|
```
|
||||||
|
|
||||||
|
Any other LEZ program needing the same single-admin pattern depends on `lez-authority` directly — see [`crates/lez-authority/README.md`](../crates/lez-authority/README.md).
|
||||||
|
|
||||||
|
### Overhead
|
||||||
|
|
||||||
|
`lez-authority` is pure host-side logic — no additional accounts, no additional instruction fields, no serialization overhead. The `Authority` type is a single `Option<AccountId>` wrapper, identical in size to the field it wraps. There is no measurable transaction-size or compute overhead introduced by routing through the library versus inline checks.
|
||||||
|
|
||||||
|
### Test coverage
|
||||||
|
|
||||||
|
16 unit tests in `crates/lez-authority/src/lib.rs` cover every method in isolation (rotation, revocation, renounced-state guards, full lifecycle). The token program adds 2 tests confirming the library is wired correctly: `test_mint_missing_authorization` (renounced path) and `test_mint_fails_when_unauthorized_with_active_authority` (unauthorized path).
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### `NewFungibleDefinitionWithAuthority`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Instruction::NewFungibleDefinitionWithAuthority {
|
||||||
|
name: String,
|
||||||
|
total_supply: u128,
|
||||||
|
mint_authority: Option<AccountId>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new fungible token definition with an explicit mint authority. Passing `None` as `mint_authority` creates a permanently fixed supply token at initialization — no separate revocation step is needed.
|
||||||
|
|
||||||
|
Required accounts (in order):
|
||||||
|
1. Token Definition account (uninitialized, authorized)
|
||||||
|
2. Token Holding account (uninitialized, authorized)
|
||||||
|
|
||||||
|
### `SetAuthority`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Instruction::SetAuthority {
|
||||||
|
new_authority: Option<AccountId>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rotates or revokes the mint authority on an existing fungible token definition.
|
||||||
|
|
||||||
|
- `Some(new_id)` — transfers authority to `new_id`. The previous authority loses all minting rights immediately.
|
||||||
|
- `None` — permanently revokes authority. The supply is fixed from this point on. This operation is irreversible.
|
||||||
|
|
||||||
|
Required accounts (in order):
|
||||||
|
1. Token Definition account (initialized, authorized by current mint authority)
|
||||||
|
|
||||||
|
### `Mint` (updated)
|
||||||
|
|
||||||
|
The existing `Mint` instruction now enforces the authority check before executing:
|
||||||
|
|
||||||
|
1. If `mint_authority` is `None` → panic with `"Mint authority has been revoked; supply is fixed"`
|
||||||
|
2. If `mint_authority` is `Some(_)` but `is_authorized` is `false` → panic with `"Unauthorized: caller is not the current authority"` (via `AuthorityError::Unauthorized`)
|
||||||
|
3. Otherwise → proceed with minting
|
||||||
|
|
||||||
|
## Authority Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ NewFungibleDefinitionWithAuthority │
|
||||||
|
│ mint_authority: Some(A) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ ACTIVE: mint_authority = Some(A) │
|
||||||
|
│ - A can mint more tokens │
|
||||||
|
│ - A can rotate authority │
|
||||||
|
│ - A can revoke authority │
|
||||||
|
└──────┬──────────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
SetAuthority │ │ SetAuthority
|
||||||
|
Some(B) │ │ None
|
||||||
|
│ │
|
||||||
|
┌─────────────────▼──┐ ┌──────▼──────────────────────┐
|
||||||
|
│ ACTIVE: Some(B) │ │ REVOKED: None │
|
||||||
|
│ (same as above, │ │ - Minting permanently │
|
||||||
|
│ authority is B) │ │ disabled │
|
||||||
|
└────────────────────┘ │ - SetAuthority fails │
|
||||||
|
│ - Supply is fixed forever │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixed Supply at Creation
|
||||||
|
|
||||||
|
Passing `mint_authority: None` to `NewFungibleDefinitionWithAuthority` skips the active state entirely:
|
||||||
|
|
||||||
|
```
|
||||||
|
NewFungibleDefinitionWithAuthority(mint_authority: None)
|
||||||
|
──▶ REVOKED immediately (supply fixed from block 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is equivalent to calling `NewFungibleDefinition` — which also sets `mint_authority: None` implicitly — but makes the intent explicit in the instruction.
|
||||||
|
|
||||||
|
## Atomicity
|
||||||
|
|
||||||
|
All state transitions are atomic. The RISC Zero zkVM executes the guest program and either:
|
||||||
|
|
||||||
|
- Commits the full output state (all account changes are applied), or
|
||||||
|
- Panics (no state is written — the pre-state is preserved exactly)
|
||||||
|
|
||||||
|
There is no mechanism for partial writes. A failed `SetAuthority` call — whether due to authorization failure, revocation check, or NFT check — leaves the definition account's `mint_authority` field unchanged.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- A rotation that fails leaves the old authority in place.
|
||||||
|
- A revocation that fails leaves the authority active.
|
||||||
|
- There is no "undefined" or "in-between" authority state.
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
All errors are surfaced as guest panics. The sequencer records them as `ProgramExecutionFailed` with the panic message as the inner string.
|
||||||
|
|
||||||
|
| Condition | Panic message |
|
||||||
|
|---|---|
|
||||||
|
| `Mint` called when `mint_authority` is `None` | `"Mint authority has been revoked; supply is fixed"` |
|
||||||
|
| `SetAuthority` called without authorization | `"Definition account must be authorized by current mint authority"` |
|
||||||
|
| `SetAuthority` called when authority already `None` | `"Mint authority is already revoked; cannot rotate a revoked authority"` |
|
||||||
|
| `SetAuthority` called on a NonFungible definition | `"Cannot set mint authority on a Non-Fungible Token definition"` |
|
||||||
|
| `Mint` called without authorization | `"Definition authorization is missing"` |
|
||||||
|
|
||||||
|
Error messages are deterministic — the same condition always produces the same message string.
|
||||||
|
|
||||||
|
## Authorization Model
|
||||||
|
|
||||||
|
The LEZ authorization model works through the `is_authorized` flag on `AccountWithMetadata`. The protocol sets this flag to `true` when the transaction includes a valid signature for that account's keypair.
|
||||||
|
|
||||||
|
Programs trust this flag rather than re-implementing signature verification. This is the correct and consistent pattern across all LEZ programs (token, AMM, stablecoin, ATA).
|
||||||
|
|
||||||
|
For `SetAuthority`:
|
||||||
|
- The definition account must have `is_authorized: true`
|
||||||
|
- This means the transaction must be signed by the key corresponding to the current `mint_authority` account ID
|
||||||
|
- If `mint_authority` is `Some(A)`, the transaction must include a signature from A's keypair
|
||||||
|
|
||||||
|
For `Mint`:
|
||||||
|
- The definition account must have `is_authorized: true`
|
||||||
|
- This means the transaction must be signed by the key corresponding to `mint_authority`
|
||||||
|
- The check is: `mint_authority.is_some()` (supply not fixed) AND `is_authorized` (correct key signed)
|
||||||
|
|
||||||
|
## Backwards Compatibility
|
||||||
|
|
||||||
|
The existing `NewFungibleDefinition` instruction is unchanged. It implicitly sets `mint_authority: None`, creating a fixed supply token. All programs that use `NewFungibleDefinition` continue to work without modification.
|
||||||
|
|
||||||
|
The `mint_authority` field is appended to `TokenDefinition::Fungible`'s Borsh serialization. Existing on-chain accounts created before this change will fail to deserialize with the new schema — this is expected for a breaking schema change and is handled by requiring a fresh deployment.
|
||||||
|
|
||||||
|
## Compute Unit Costs
|
||||||
|
|
||||||
|
Measured on LEZ devnet (local sequencer standalone mode — devnet/localnet).
|
||||||
|
Run with `RISC0_DEV_MODE=0` — real ZK proofs generated.
|
||||||
|
Reproducible: clone repo, run `scripts/demo.sh` with `RISC0_DEV_MODE=0`, observe `execution time:` lines in sequencer logs.
|
||||||
|
|
||||||
|
| Operation | Tx Hash | Block | Execution Time (RISC0_DEV_MODE=0) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `NewFungibleDefinitionWithAuthority` | `14197f9113ff000e81b7545c671942b286ef19bae7122ba280a0a620b8e01ca1` | 410 | 15.92ms |
|
||||||
|
| `Mint` (authority active) | `99f00dbe40600d0c8bb745b74980c2241f1e7a6daa1291f5cef6b9ea27c82bd9` | 411 | 19.29ms |
|
||||||
|
| `SetAuthority` (rotate) | `d865e26dfb5f82a5528aa9a0882307a73b00ffc4fa7825f0e7b5d0888d5c87fc` | 414 | 13.40ms |
|
||||||
|
| `SetAuthority` (revoke to None) | `9408ef7ffd3efdbafbe2dd5bf243da32edd1a4d52f9709b5cfc92cb696b8956e` | 415 | 15.74ms |
|
||||||
|
| `Mint` (rejected — authority revoked) | `5228cc62094a91e479b86a3aee067809f18674465ac72d8623d1ed770ab496de` | 416 | 9.84ms |
|
||||||
|
|
||||||
|
Rejected operations cost ~38% less than successful ones because execution halts at the
|
||||||
|
authority guard before any account writes — confirming rejection is via the correct code
|
||||||
|
path (`"Mint authority has been revoked; supply is fixed"`), not a side effect.
|
||||||
|
|
||||||
|
## Moderator Trust Model
|
||||||
|
|
||||||
|
The mint authority is fully trusted within the scope of a single token definition. The protocol enforces:
|
||||||
|
|
||||||
|
- Only the current authority can rotate or revoke itself
|
||||||
|
- Revocation is permanent and cannot be undone by anyone
|
||||||
|
- No one can "re-grant" authority after revocation — not even the original creator
|
||||||
|
|
||||||
|
There is no multi-sig or timelock on authority operations in this implementation. A single keypair controls the authority. Applications requiring shared governance over minting should implement a multisig wrapper program (see LP-0002) that holds the mint authority keypair.
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
**What the protocol prevents:**
|
||||||
|
- Unauthorized minting (any account other than the current authority)
|
||||||
|
- Minting after revocation
|
||||||
|
- Re-granting authority after revocation
|
||||||
|
- Partial authority state (impossible due to RISC Zero atomicity)
|
||||||
|
|
||||||
|
**What the protocol does NOT prevent:**
|
||||||
|
- Authority key compromise — if the authority's private key is stolen, the attacker can mint or rotate authority before the legitimate holder can revoke
|
||||||
|
- Front-running on `SetAuthority` — if an attacker observes a revocation transaction in the mempool, they could submit a mint transaction first (mempool ordering is not guaranteed)
|
||||||
|
- The original creator reclaiming authority — once authority is rotated to another account, the original creator has no special power to reclaim it
|
||||||
|
|
||||||
|
**Mitigations for key compromise:**
|
||||||
|
- Rotate authority to a new keypair immediately if compromise is suspected
|
||||||
|
- Use a multisig program as the authority for high-value tokens
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- No freeze authority (out of scope per LP-0013)
|
||||||
|
- No capped supply with a cap distinct from `total_supply` (out of scope)
|
||||||
|
- Authority check uses `is_authorized` flag which requires the transaction to be signed by the authority keypair — there is no support for program-owned authorities (PDAs) as mint authorities in this implementation
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
use common::transaction::LeeTransaction;
|
||||||
|
use lee::{
|
||||||
|
AccountId, PublicTransaction,
|
||||||
|
program::Program,
|
||||||
|
public_transaction::{Message, WitnessSet},
|
||||||
|
};
|
||||||
|
use sequencer_service_rpc::RpcClient as _;
|
||||||
|
use token_core::Instruction;
|
||||||
|
use wallet::WalletCore;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let wallet_core = WalletCore::from_env().expect("Wallet env not configured");
|
||||||
|
|
||||||
|
let definition_id: AccountId = std::env::args_os()
|
||||||
|
.nth(1).unwrap().into_string().unwrap().parse().unwrap();
|
||||||
|
let supply_id: AccountId = std::env::args_os()
|
||||||
|
.nth(2).unwrap().into_string().unwrap().parse().unwrap();
|
||||||
|
let name: String = std::env::args_os()
|
||||||
|
.nth(3).unwrap().into_string().unwrap();
|
||||||
|
let total_supply: u128 = std::env::args_os()
|
||||||
|
.nth(4).unwrap().into_string().unwrap().parse().unwrap();
|
||||||
|
let authority_arg = std::env::args_os()
|
||||||
|
.nth(5).unwrap().into_string().unwrap();
|
||||||
|
let mint_authority: Option<AccountId> = if authority_arg == "none" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(authority_arg.parse().unwrap())
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Creating token '{}' total_supply={} mint_authority={:?}", name, total_supply, mint_authority);
|
||||||
|
|
||||||
|
let program = Program::token();
|
||||||
|
let instruction = Instruction::NewFungibleDefinitionWithAuthority {
|
||||||
|
name,
|
||||||
|
total_supply,
|
||||||
|
mint_authority,
|
||||||
|
};
|
||||||
|
let instruction_data =
|
||||||
|
Program::serialize_instruction(instruction).expect("Instruction serialization failed");
|
||||||
|
|
||||||
|
let def_signing_key = wallet_core
|
||||||
|
.storage()
|
||||||
|
.key_chain()
|
||||||
|
.pub_account_signing_key(definition_id)
|
||||||
|
.expect("definition account signing key not found");
|
||||||
|
let sup_signing_key = wallet_core
|
||||||
|
.storage()
|
||||||
|
.key_chain()
|
||||||
|
.pub_account_signing_key(supply_id)
|
||||||
|
.expect("supply account signing key not found");
|
||||||
|
|
||||||
|
let nonces = wallet_core
|
||||||
|
.get_accounts_nonces(vec![definition_id, supply_id])
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch nonces");
|
||||||
|
|
||||||
|
let signing_keys = [def_signing_key, sup_signing_key];
|
||||||
|
let message = Message::try_new(
|
||||||
|
program.id(),
|
||||||
|
vec![definition_id, supply_id],
|
||||||
|
nonces,
|
||||||
|
instruction_data,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let witness_set = WitnessSet::for_message(&message, &signing_keys);
|
||||||
|
let tx = PublicTransaction::new(message, witness_set);
|
||||||
|
|
||||||
|
let response = wallet_core
|
||||||
|
.sequencer_client
|
||||||
|
.send_transaction(LeeTransaction::Public(tx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("✅ Token created. Transaction: {:?}", response);
|
||||||
|
}
|
||||||
61
examples/program_deployment/src/bin/run_set_authority.rs
Normal file
61
examples/program_deployment/src/bin/run_set_authority.rs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
use common::transaction::LeeTransaction;
|
||||||
|
use lee::{
|
||||||
|
AccountId, PublicTransaction,
|
||||||
|
program::Program,
|
||||||
|
public_transaction::{Message, WitnessSet},
|
||||||
|
};
|
||||||
|
use sequencer_service_rpc::RpcClient as _;
|
||||||
|
use token_core::Instruction;
|
||||||
|
use wallet::WalletCore;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let wallet_core = WalletCore::from_env().expect("Wallet env not configured");
|
||||||
|
|
||||||
|
let definition_id: AccountId = std::env::args_os()
|
||||||
|
.nth(1).unwrap().into_string().unwrap().parse().unwrap();
|
||||||
|
let new_authority_arg = std::env::args_os()
|
||||||
|
.nth(2).unwrap().into_string().unwrap();
|
||||||
|
let new_authority: Option<AccountId> = if new_authority_arg == "none" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(new_authority_arg.parse().unwrap())
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Setting authority on {} -> {:?}", definition_id, new_authority);
|
||||||
|
|
||||||
|
let program = Program::token();
|
||||||
|
let instruction = Instruction::SetAuthority { new_authority };
|
||||||
|
let instruction_data =
|
||||||
|
Program::serialize_instruction(instruction).expect("Instruction serialization failed");
|
||||||
|
|
||||||
|
let def_signing_key = wallet_core
|
||||||
|
.storage()
|
||||||
|
.key_chain()
|
||||||
|
.pub_account_signing_key(definition_id)
|
||||||
|
.expect("definition account signing key not found");
|
||||||
|
|
||||||
|
let nonces = wallet_core
|
||||||
|
.get_accounts_nonces(vec![definition_id])
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch nonces");
|
||||||
|
|
||||||
|
let signing_keys = [def_signing_key];
|
||||||
|
let message = Message::try_new(
|
||||||
|
program.id(),
|
||||||
|
vec![definition_id],
|
||||||
|
nonces,
|
||||||
|
instruction_data,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let witness_set = WitnessSet::for_message(&message, &signing_keys);
|
||||||
|
let tx = PublicTransaction::new(message, witness_set);
|
||||||
|
|
||||||
|
let response = wallet_core
|
||||||
|
.sequencer_client
|
||||||
|
.send_transaction(LeeTransaction::Public(tx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("✅ Authority updated. Transaction: {:?}", response);
|
||||||
|
}
|
||||||
@ -164,9 +164,10 @@ pub fn new_definition(
|
|||||||
let call_token_lp_lock = ChainedCall::new(
|
let call_token_lp_lock = ChainedCall::new(
|
||||||
token_program_id,
|
token_program_id,
|
||||||
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
||||||
&token_core::Instruction::NewFungibleDefinition {
|
&token_core::Instruction::NewFungibleDefinitionWithAuthority {
|
||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: MINIMUM_LIQUIDITY,
|
total_supply: MINIMUM_LIQUIDITY,
|
||||||
|
mint_authority: Some(pool_lp_auth.account_id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_pda_seeds(vec![
|
.with_pda_seeds(vec![
|
||||||
@ -180,6 +181,7 @@ pub fn new_definition(
|
|||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: MINIMUM_LIQUIDITY,
|
total_supply: MINIMUM_LIQUIDITY,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(pool_lp_auth.account_id),
|
||||||
});
|
});
|
||||||
|
|
||||||
let call_token_lp_user = ChainedCall::new(
|
let call_token_lp_user = ChainedCall::new(
|
||||||
|
|||||||
@ -536,10 +536,11 @@ impl ChainedCallForTests {
|
|||||||
|
|
||||||
ChainedCall::new(
|
ChainedCall::new(
|
||||||
TOKEN_PROGRAM_ID,
|
TOKEN_PROGRAM_ID,
|
||||||
vec![pool_lp_auth, lp_lock_holding_auth],
|
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
||||||
&token_core::Instruction::NewFungibleDefinition {
|
&token_core::Instruction::NewFungibleDefinitionWithAuthority {
|
||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: MINIMUM_LIQUIDITY,
|
total_supply: MINIMUM_LIQUIDITY,
|
||||||
|
mint_authority: Some(pool_lp_auth.account_id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_pda_seeds(vec![
|
.with_pda_seeds(vec![
|
||||||
@ -789,6 +790,7 @@ impl AccountWithMetadataForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::lp_supply_init(),
|
total_supply: BalanceForTests::lp_supply_init(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -814,6 +816,7 @@ impl AccountWithMetadataForTests {
|
|||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: MINIMUM_LIQUIDITY,
|
total_supply: MINIMUM_LIQUIDITY,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(IdForTests::token_lp_definition_id()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -831,6 +834,7 @@ impl AccountWithMetadataForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::lp_supply_init(),
|
total_supply: BalanceForTests::lp_supply_init(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -2813,9 +2817,10 @@ fn test_new_definition_lp_symmetric_amounts() {
|
|||||||
let expected_lp_lock_call = ChainedCall::new(
|
let expected_lp_lock_call = ChainedCall::new(
|
||||||
TOKEN_PROGRAM_ID,
|
TOKEN_PROGRAM_ID,
|
||||||
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
||||||
&token_core::Instruction::NewFungibleDefinition {
|
&token_core::Instruction::NewFungibleDefinitionWithAuthority {
|
||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: MINIMUM_LIQUIDITY,
|
total_supply: MINIMUM_LIQUIDITY,
|
||||||
|
mint_authority: Some(pool_lp_auth.account_id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_pda_seeds(vec![
|
.with_pda_seeds(vec![
|
||||||
@ -2876,9 +2881,10 @@ fn test_minimum_liquidity_lock_and_remove_all_user_lp() {
|
|||||||
let expected_lock_call = ChainedCall::new(
|
let expected_lock_call = ChainedCall::new(
|
||||||
TOKEN_PROGRAM_ID,
|
TOKEN_PROGRAM_ID,
|
||||||
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
||||||
&token_core::Instruction::NewFungibleDefinition {
|
&token_core::Instruction::NewFungibleDefinitionWithAuthority {
|
||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: MINIMUM_LIQUIDITY,
|
total_supply: MINIMUM_LIQUIDITY,
|
||||||
|
mint_authority: Some(pool_lp_auth.account_id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_pda_seeds(vec![
|
.with_pda_seeds(vec![
|
||||||
|
|||||||
@ -41,6 +41,7 @@ fn definition_account() -> AccountWithMetadata {
|
|||||||
name: "TEST".to_string(),
|
name: "TEST".to_string(),
|
||||||
total_supply: 1000,
|
total_supply: 1000,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: nssa_core::account::Nonce(0),
|
nonce: nssa_core::account::Nonce(0),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -334,6 +334,7 @@ impl Accounts {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: Balances::token_a_supply(),
|
total_supply: Balances::token_a_supply(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_a_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -347,6 +348,7 @@ impl Accounts {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: Balances::token_b_supply(),
|
total_supply: Balances::token_b_supply(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_b_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -360,6 +362,7 @@ impl Accounts {
|
|||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: Balances::token_lp_supply(),
|
total_supply: Balances::token_lp_supply(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_lp_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -636,6 +639,7 @@ impl Accounts {
|
|||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: Balances::token_lp_supply_add(),
|
total_supply: Balances::token_lp_supply_add(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_lp_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -728,6 +732,7 @@ impl Accounts {
|
|||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: Balances::token_lp_supply_remove(),
|
total_supply: Balances::token_lp_supply_remove(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_lp_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -741,6 +746,7 @@ impl Accounts {
|
|||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: 0,
|
total_supply: 0,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -845,6 +851,7 @@ impl Accounts {
|
|||||||
name: String::from("LP Token"),
|
name: String::from("LP Token"),
|
||||||
total_supply: Balances::lp_supply_init(),
|
total_supply: Balances::lp_supply_init(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_lp_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -1173,12 +1180,7 @@ fn pool_definition(account: &Account) -> PoolDefinition {
|
|||||||
|
|
||||||
fn fungible_total_supply(account: &Account) -> u128 {
|
fn fungible_total_supply(account: &Account) -> u128 {
|
||||||
let definition = TokenDefinition::try_from(&account.data).expect("expected token definition");
|
let definition = TokenDefinition::try_from(&account.data).expect("expected token definition");
|
||||||
let TokenDefinition::Fungible {
|
let TokenDefinition::Fungible { total_supply, .. } = definition else {
|
||||||
name: _,
|
|
||||||
total_supply,
|
|
||||||
metadata_id: _,
|
|
||||||
} = definition
|
|
||||||
else {
|
|
||||||
panic!("expected fungible token definition")
|
panic!("expected fungible token definition")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -85,6 +85,7 @@ impl Accounts {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 1_000_000_u128,
|
total_supply: 1_000_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -122,6 +123,7 @@ impl Accounts {
|
|||||||
name: String::from("Foreign Gold"),
|
name: String::from("Foreign Gold"),
|
||||||
total_supply: 1_000_000_u128,
|
total_supply: 1_000_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -496,6 +498,7 @@ fn ata_burn() {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 700_000_u128,
|
total_supply: 700_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,6 +108,7 @@ impl Accounts {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: Balances::user_holding_init(),
|
total_supply: Balances::user_holding_init(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -133,6 +134,7 @@ impl Accounts {
|
|||||||
name: String::from("DAI"),
|
name: String::from("DAI"),
|
||||||
total_supply: Balances::stablecoin_supply_init(),
|
total_supply: Balances::stablecoin_supply_init(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,6 +62,7 @@ impl Accounts {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 1_000_000_u128,
|
total_supply: 1_000_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -75,6 +76,7 @@ impl Accounts {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 1_000_000_u128,
|
total_supply: 1_000_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -165,6 +167,7 @@ fn token_new_fungible_definition() {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 1_000_000_u128,
|
total_supply: 1_000_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(1),
|
nonce: Nonce(1),
|
||||||
}
|
}
|
||||||
@ -416,6 +419,7 @@ fn token_burn() {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 800_000_u128,
|
total_supply: 800_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
}
|
}
|
||||||
@ -465,6 +469,7 @@ fn token_mint() {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 1_500_000_u128,
|
total_supply: 1_500_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(1),
|
nonce: Nonce(1),
|
||||||
}
|
}
|
||||||
@ -586,6 +591,7 @@ fn token_mint_fresh_authorized_public_recipient() {
|
|||||||
name: String::from("Gold"),
|
name: String::from("Gold"),
|
||||||
total_supply: 1_500_000_u128,
|
total_supply: 1_500_000_u128,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(Ids::token_definition()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(1),
|
nonce: Nonce(1),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ fn collateral_definition_account() -> AccountWithMetadata {
|
|||||||
name: "SNT".to_owned(),
|
name: "SNT".to_owned(),
|
||||||
total_supply: 1_000_000,
|
total_supply: 1_000_000,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -156,6 +157,7 @@ fn stablecoin_definition_account() -> AccountWithMetadata {
|
|||||||
name: "DAI".to_owned(),
|
name: "DAI".to_owned(),
|
||||||
total_supply: 1_000_000,
|
total_supply: 1_000_000,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -389,6 +391,7 @@ fn open_position_rejects_mismatched_token_definition() {
|
|||||||
name: "OTHER".to_owned(),
|
name: "OTHER".to_owned(),
|
||||||
total_supply: 1,
|
total_supply: 1,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,3 +9,4 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||||
token_core = { path = "core" }
|
token_core = { path = "core" }
|
||||||
|
lez-authority = { path = "../../crates/lez-authority" }
|
||||||
|
|||||||
302
programs/token/README.md
Normal file
302
programs/token/README.md
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
# LP-0013: Token Program - Mint Authority
|
||||||
|
|
||||||
|
This branch adds a mint authority model to the LEZ token program, enabling variable supply tokens, permissioned issuance, and the standard "revoke to fix supply" pattern expected by wallets and DeFi protocols.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
The existing token program supported creating tokens with a fixed total supply but had no mechanism to control who could mint additional tokens after creation. This PR adds:
|
||||||
|
|
||||||
|
- `mint_authority: Option<AccountId>` field on `TokenDefinition::Fungible`
|
||||||
|
- `NewFungibleDefinitionWithAuthority` instruction — create a token and set a mint authority at initialization
|
||||||
|
- `SetAuthority` instruction — rotate the authority to a new account, or revoke it permanently by setting it to `None`
|
||||||
|
- Updated `Mint` instruction — now enforces that `mint_authority` is `Some` before allowing minting
|
||||||
|
- Fully backwards compatible — the existing `NewFungibleDefinition` instruction still works, creating tokens with `mint_authority: None` (fixed supply)
|
||||||
|
|
||||||
|
The design follows Solana's SPL Token authority model: a single `Option<AccountId>` field simultaneously encodes who the authority is and whether minting is possible. `None` is self-describing — no authority, no minting, ever.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `programs/token/core/src/lib.rs` | Added `mint_authority` field to `TokenDefinition::Fungible`; added `NewFungibleDefinitionWithAuthority` and `SetAuthority` instruction variants |
|
||||||
|
| `programs/token/src/new_definition.rs` | Added `new_fungible_definition_with_authority()` function |
|
||||||
|
| `programs/token/src/set_authority.rs` | New file — implements authority rotation and revocation |
|
||||||
|
| `programs/token/src/mint.rs` | Added authority check before minting |
|
||||||
|
| `programs/token/src/lib.rs` | Exposed `set_authority` module |
|
||||||
|
| `programs/token/methods/guest/src/bin/token.rs` | Wired both new instructions into the SPEL guest dispatch |
|
||||||
|
| `programs/token/src/tests.rs` | 8 new unit tests covering every authority state transition |
|
||||||
|
| `programs/integration_tests/tests/token.rs` | 3 new integration tests running against a real `V03State` |
|
||||||
|
|
||||||
|
## Authority Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
NewFungibleDefinitionWithAuthority
|
||||||
|
mint_authority: Some(A) ──▶ minting allowed (only A can mint)
|
||||||
|
│
|
||||||
|
├── SetAuthority(Some(B)) ──▶ authority transferred to B
|
||||||
|
│
|
||||||
|
└── SetAuthority(None) ──▶ supply permanently fixed
|
||||||
|
│
|
||||||
|
└── Mint ──▶ PANIC: "Mint authority has been revoked; supply is fixed"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Atomicity
|
||||||
|
|
||||||
|
Authority rotation and revocation are atomic. The RISC Zero zkVM either commits the full output state or panics — there is no partial write. A failed `SetAuthority` call leaves the authority unchanged.
|
||||||
|
|
||||||
|
### Error Codes
|
||||||
|
|
||||||
|
Authority-related panics now originate from `lez-authority`'s `AuthorityError` (see [docs/authority-model.md](../../docs/authority-model.md#admin-authority-library-rfp-001)):
|
||||||
|
|
||||||
|
| Panic message | Condition |
|
||||||
|
|---|---|
|
||||||
|
| `"Mint authority has been revoked; supply is fixed"` | `Mint` called when `mint_authority` is `None` (wraps `AuthorityError::Renounced`) |
|
||||||
|
| `"Renounced: authority has been permanently revoked"` | `SetAuthority` called when authority already `None` |
|
||||||
|
| `"Unauthorized: caller is not the current authority"` | `SetAuthority` or `Mint` called without the correct signature |
|
||||||
|
| `"Cannot set mint authority on a Non-Fungible Token definition"` | `SetAuthority` called on an NFT definition |
|
||||||
|
|
||||||
|
## SDK
|
||||||
|
|
||||||
|
The SDK is `token_core` — the same crate modified in this PR. Downstream consumers import `token_core::Instruction` and get both new variants automatically. No separate SDK crate is needed; this follows the same pattern as `amm_core`, `stablecoin_core`, and `ata_core`.
|
||||||
|
|
||||||
|
## Authority Enforcement
|
||||||
|
|
||||||
|
Authorization checks in `mint.rs` and `set_authority.rs` are implemented via [`lez-authority`](../../crates/lez-authority) — a standalone, reusable admin-authority crate (see [RFP-001](https://github.com/logos-co/rfp/blob/master/RFPs/RFP-001-admin-authority-lib.md)) - Any LEZ program needing the same single-admin pattern can depend on it directly:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
lez-authority = { path = "../../crates/lez-authority" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use lez_authority::Authority;
|
||||||
|
|
||||||
|
let auth = Authority::from_option(definition.mint_authority);
|
||||||
|
auth.require(is_authorized)?; // Unauthorized or Renounced
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use token_core::Instruction;
|
||||||
|
|
||||||
|
// Create a token with mint authority
|
||||||
|
let ix = Instruction::NewFungibleDefinitionWithAuthority {
|
||||||
|
name: String::from("Gold"),
|
||||||
|
total_supply: 1_000_000,
|
||||||
|
mint_authority: Some(authority_account_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rotate authority
|
||||||
|
let ix = Instruction::SetAuthority {
|
||||||
|
new_authority: Some(new_authority_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Revoke authority permanently
|
||||||
|
let ix = Instruction::SetAuthority {
|
||||||
|
new_authority: None,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust (stable) — install via [rustup](https://rustup.rs/)
|
||||||
|
- RISC Zero toolchain:
|
||||||
|
```bash
|
||||||
|
curl -L https://risczero.com/install | bash
|
||||||
|
rzup install
|
||||||
|
```
|
||||||
|
- LEZ wallet and sequencer from [logos-blockchain/logos-execution-zone](https://github.com/youthisguy/logos-execution-zone)
|
||||||
|
|
||||||
|
## Build & Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/youthisguy/lez-programs.git
|
||||||
|
cd lez-programs
|
||||||
|
|
||||||
|
# Run all tests (skips ZK proof generation)
|
||||||
|
RISC0_DEV_MODE=1 cargo test --release
|
||||||
|
|
||||||
|
# Run token-specific unit tests
|
||||||
|
RISC0_DEV_MODE=1 cargo test --release -p token_program
|
||||||
|
|
||||||
|
# Run token integration tests
|
||||||
|
RISC0_DEV_MODE=1 cargo test --release -p integration_tests --test token
|
||||||
|
```
|
||||||
|
|
||||||
|
All 245+ tests pass. The 8 new unit tests and 3 new integration tests are included in the count.
|
||||||
|
|
||||||
|
## End-to-End Demo
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Start all three services in separate terminals:
|
||||||
|
|
||||||
|
**Terminal 1 — Bedrock:**
|
||||||
|
```bash
|
||||||
|
cd logos-execution-zone/bedrock
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 2 — Sequencer (after bedrock shows "proposed block"):**
|
||||||
|
```bash
|
||||||
|
cd logos-execution-zone/lez/sequencer/service
|
||||||
|
RUST_LOG=info RISC0_DEV_MODE=1 cargo run --release -p sequencer_service configs/debug/sequencer_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 3 — Indexer:**
|
||||||
|
```bash
|
||||||
|
cd logos-execution-zone/lez/indexer/service
|
||||||
|
RUST_LOG=info RISC0_DEV_MODE=1 cargo run --release -p indexer_service configs/indexer_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 4 — Wallet commands:**
|
||||||
|
```bash
|
||||||
|
export LEZ_WALLET_HOME_DIR=logos-execution-zone/lez/wallet/configs/debug
|
||||||
|
cd logos-execution-zone
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet check-health
|
||||||
|
# ✅ All looks good!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo Walkthrough
|
||||||
|
|
||||||
|
**1. Create accounts:**
|
||||||
|
```bash
|
||||||
|
./target/release/wallet account new public --label "token-def"
|
||||||
|
./target/release/wallet account new public --label "token-supply"
|
||||||
|
./target/release/wallet account new public --label "new-authority"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create a token WITH mint authority:**
|
||||||
|
```bash
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token new-with-authority \
|
||||||
|
--definition-account-id "Public/<DEF_ID>" \
|
||||||
|
--supply-account-id "Public/<SUPPLY_ID>" \
|
||||||
|
--name "Gold" \
|
||||||
|
--total-supply 1000000 \
|
||||||
|
--mint-authority "<DEF_ID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Verify on-chain — mint_authority is set:**
|
||||||
|
```bash
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet account get \
|
||||||
|
--account-id "Public/<DEF_ID>"
|
||||||
|
# {"Fungible":{"name":"Gold","total_supply":1000000,"metadata_id":null,"mint_authority":"<DEF_ID>"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Mint additional tokens:**
|
||||||
|
```bash
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token mint \
|
||||||
|
--definition "Public/<DEF_ID>" \
|
||||||
|
--holder "Public/<SUPPLY_ID>" \
|
||||||
|
--amount 500000
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Rotate authority to a new account:**
|
||||||
|
```bash
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token set-authority \
|
||||||
|
--definition-account-id "Public/<DEF_ID>" \
|
||||||
|
--new-authority "<NEW_AUTHORITY_ID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Revoke authority permanently:**
|
||||||
|
```bash
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token set-authority \
|
||||||
|
--definition-account-id "Public/<DEF_ID>" \
|
||||||
|
--new-authority "none"
|
||||||
|
```
|
||||||
|
|
||||||
|
**7. Verify final state — mint_authority is null, total_supply updated:**
|
||||||
|
```bash
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet account get \
|
||||||
|
--account-id "Public/<DEF_ID>"
|
||||||
|
# {"Fungible":{"name":"Gold","total_supply":1500000,"metadata_id":null,"mint_authority":null}}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Running the e2e Demo Script
|
||||||
|
|
||||||
|
After setting up all three services (bedrock, sequencer, indexer), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RISC0_DEV_MODE=0 \
|
||||||
|
WALLET_BIN=/path/to/logos-execution-zone/target/release/wallet \
|
||||||
|
LEZ_WALLET_HOME_DIR=/path/to/logos-execution-zone/lez/wallet/configs/debug \
|
||||||
|
bash scripts/demo.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Integrations
|
||||||
|
|
||||||
|
Two example Rust programs are in `examples/program_deployment/src/bin/`:
|
||||||
|
|
||||||
|
**Fixed supply token (authority revoked at creation):**
|
||||||
|
```bash
|
||||||
|
# Create token with no authority — supply is fixed immediately
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token new-with-authority \
|
||||||
|
--definition-account-id "Public/<DEF_ID>" \
|
||||||
|
--supply-account-id "Public/<SUPPLY_ID>" \
|
||||||
|
--name "FixedCoin" \
|
||||||
|
--total-supply 21000000 \
|
||||||
|
--mint-authority "none"
|
||||||
|
# mint_authority: null from the start — nobody can ever mint more
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variable supply token (authority set, then used):**
|
||||||
|
```bash
|
||||||
|
# Create with authority
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token new-with-authority \
|
||||||
|
--definition-account-id "Public/<DEF_ID>" \
|
||||||
|
--supply-account-id "Public/<SUPPLY_ID>" \
|
||||||
|
--name "GovToken" \
|
||||||
|
--total-supply 1000000 \
|
||||||
|
--mint-authority "<DEF_ID>"
|
||||||
|
|
||||||
|
# Mint more later
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token mint \
|
||||||
|
--definition "Public/<DEF_ID>" \
|
||||||
|
--holder "Public/<SUPPLY_ID>" \
|
||||||
|
--amount 250000
|
||||||
|
|
||||||
|
# Lock supply when ready
|
||||||
|
SEQUENCER_URL=http://127.0.0.1:3040 ./target/release/wallet token set-authority \
|
||||||
|
--definition-account-id "Public/<DEF_ID>" \
|
||||||
|
--new-authority "none"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compute Unit Costs
|
||||||
|
|
||||||
|
Measured on LEZ devnet (local sequencer standalone mode — devnet == localnet).
|
||||||
|
Run with `RISC0_DEV_MODE=0` — real ZK proofs generated.
|
||||||
|
Reproducible: clone repo, run `scripts/demo.sh` with `RISC0_DEV_MODE=0`, observe `execution time:` lines in sequencer logs.
|
||||||
|
|
||||||
|
| Operation | Tx Hash | Block | Execution Time (RISC0_DEV_MODE=0) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `NewFungibleDefinitionWithAuthority` | `14197f9113ff000e81b7545c671942b286ef19bae7122ba280a0a620b8e01ca1` | 410 | 15.92ms |
|
||||||
|
| `Mint` (authority active) | `99f00dbe40600d0c8bb745b74980c2241f1e7a6daa1291f5cef6b9ea27c82bd9` | 411 | 19.29ms |
|
||||||
|
| `SetAuthority` (rotate) | `d865e26dfb5f82a5528aa9a0882307a73b00ffc4fa7825f0e7b5d0888d5c87fc` | 414 | 13.40ms |
|
||||||
|
| `SetAuthority` (revoke to None) | `9408ef7ffd3efdbafbe2dd5bf243da32edd1a4d52f9709b5cfc92cb696b8956e` | 415 | 15.74ms |
|
||||||
|
| `Mint` (rejected — authority revoked) | `5228cc62094a91e479b86a3aee067809f18674465ac72d8623d1ed770ab496de` | 416 | 9.84ms |
|
||||||
|
|
||||||
|
Rejected operations cost ~38% less than successful ones because execution halts at the
|
||||||
|
authority guard before any account writes — confirming rejection is via the correct code
|
||||||
|
path (`"Mint authority has been revoked; supply is fixed"`).
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
**Why `Option<AccountId>` and not a separate `is_fixed_supply: bool`?**
|
||||||
|
One field encodes both who the authority is and whether minting is possible. `None` is self-describing — no authority, no minting, ever. Separate fields risk inconsistent state (`is_fixed_supply: false` but `mint_authority: None`).
|
||||||
|
|
||||||
|
**Why is `SetAuthority` a separate instruction and not part of `NewDefinition`?**
|
||||||
|
Separation of concerns. Creation and authority management are different operations with different signers and different lifecycle stages. This also matches SPL Token's design.
|
||||||
|
|
||||||
|
**Why does `mint.rs` check `is_none()` rather than comparing account IDs?**
|
||||||
|
The LEZ authorization model sets `is_authorized: true` on an account when the transaction includes a valid signature for that account. The program trusts the protocol's authorization flag rather than re-implementing signature verification. This is the correct pattern for all LEZ programs.
|
||||||
|
|
||||||
|
**Why does `Authority::require()` check revocation before authorization?**
|
||||||
|
If `mint_authority` is `None`, there is no authorized caller possible — the clearer error is `Renounced` rather than `Unauthorized`. This ordering lives in `lez-authority` itself, not in the token program, so every consumer of the library gets the same precedence for free.
|
||||||
|
|
||||||
|
## Related Repos
|
||||||
|
|
||||||
|
- [`youthisguy/logos-execution-zone`](https://github.com/youthisguy/logos-execution-zone) — mirrored changes to the sequencer, wallet CLI, and guest binary. The wallet CLI gains two new commands: `token new-with-authority` and `token set-authority`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@ -63,6 +63,21 @@ pub enum Instruction {
|
|||||||
/// - NFT Master Token Holding account (authorized),
|
/// - NFT Master Token Holding account (authorized),
|
||||||
/// - NFT Printed Copy Token Holding account (uninitialized, authorized).
|
/// - NFT Printed Copy Token Holding account (uninitialized, authorized).
|
||||||
PrintNft,
|
PrintNft,
|
||||||
|
/// Create a new fungible token definition with a mint authority.
|
||||||
|
///
|
||||||
|
/// Required accounts:
|
||||||
|
/// - Token Definition account (uninitialized, authorized),
|
||||||
|
/// - Token Holding account (uninitialized, authorized).
|
||||||
|
NewFungibleDefinitionWithAuthority {
|
||||||
|
name: String,
|
||||||
|
total_supply: u128,
|
||||||
|
mint_authority: Option<AccountId>,
|
||||||
|
},
|
||||||
|
/// Set or revoke the mint authority on a fungible token definition.
|
||||||
|
///
|
||||||
|
/// Required accounts:
|
||||||
|
/// - Token Definition account (initialized, authorized).
|
||||||
|
SetAuthority { new_authority: Option<AccountId> },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@ -84,6 +99,7 @@ pub enum TokenDefinition {
|
|||||||
name: String,
|
name: String,
|
||||||
total_supply: u128,
|
total_supply: u128,
|
||||||
metadata_id: Option<AccountId>,
|
metadata_id: Option<AccountId>,
|
||||||
|
mint_authority: Option<AccountId>,
|
||||||
},
|
},
|
||||||
NonFungible {
|
NonFungible {
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@ -31,7 +31,7 @@ mod token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new fungible token definition without metadata.
|
/// Create a new fungible token definition without metadata.
|
||||||
/// Definition and holding targets must be uninitialized and authorized.
|
/// Supply is fixed — no mint authority is set.
|
||||||
#[instruction]
|
#[instruction]
|
||||||
pub fn new_fungible_definition(
|
pub fn new_fungible_definition(
|
||||||
definition_target_account: AccountWithMetadata,
|
definition_target_account: AccountWithMetadata,
|
||||||
@ -50,8 +50,45 @@ mod token {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new fungible token definition with an optional mint authority.
|
||||||
|
/// `mint_authority: Some(id)` enables future minting; `None` fixes supply immediately.
|
||||||
|
#[instruction]
|
||||||
|
pub fn new_fungible_definition_with_authority(
|
||||||
|
definition_target_account: AccountWithMetadata,
|
||||||
|
holding_target_account: AccountWithMetadata,
|
||||||
|
name: String,
|
||||||
|
total_supply: u128,
|
||||||
|
mint_authority: Option<nssa_core::account::AccountId>,
|
||||||
|
) -> SpelResult {
|
||||||
|
Ok(spel_framework::SpelOutput::execute(
|
||||||
|
token_program::new_definition::new_fungible_definition_with_authority(
|
||||||
|
definition_target_account,
|
||||||
|
holding_target_account,
|
||||||
|
name,
|
||||||
|
total_supply,
|
||||||
|
mint_authority,
|
||||||
|
),
|
||||||
|
vec![],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate or revoke the mint authority on a fungible token definition.
|
||||||
|
/// `new_authority: Some(id)` rotates; `None` permanently revokes (fixed supply).
|
||||||
|
#[instruction]
|
||||||
|
pub fn set_authority(
|
||||||
|
definition_account: AccountWithMetadata,
|
||||||
|
new_authority: Option<nssa_core::account::AccountId>,
|
||||||
|
) -> SpelResult {
|
||||||
|
Ok(spel_framework::SpelOutput::execute(
|
||||||
|
token_program::set_authority::set_authority(
|
||||||
|
definition_account,
|
||||||
|
new_authority,
|
||||||
|
),
|
||||||
|
vec![],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new fungible or non-fungible token definition with metadata.
|
/// Create a new fungible or non-fungible token definition with metadata.
|
||||||
/// Definition, holding, and metadata targets must be uninitialized and authorized.
|
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::boxed_local,
|
clippy::boxed_local,
|
||||||
reason = "boxed metadata keeps the instruction argument size bounded on the stack"
|
reason = "boxed metadata keeps the instruction argument size bounded on the stack"
|
||||||
@ -77,7 +114,6 @@ mod token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize a token holding account for a given token definition.
|
/// Initialize a token holding account for a given token definition.
|
||||||
/// The holding target must be uninitialized and authorized.
|
|
||||||
#[instruction]
|
#[instruction]
|
||||||
pub fn initialize_account(
|
pub fn initialize_account(
|
||||||
ctx: ProgramContext,
|
ctx: ProgramContext,
|
||||||
@ -109,7 +145,7 @@ mod token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mint new tokens to the holder's account.
|
/// Mint new tokens to the holder's account.
|
||||||
/// Fresh public holders must be explicitly authorized in the same transaction.
|
/// Requires an active mint authority on the token definition.
|
||||||
#[instruction]
|
#[instruction]
|
||||||
pub fn mint(
|
pub fn mint(
|
||||||
ctx: ProgramContext,
|
ctx: ProgramContext,
|
||||||
@ -126,7 +162,6 @@ mod token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Print a new NFT from the master copy.
|
/// Print a new NFT from the master copy.
|
||||||
/// The printed copy target must be uninitialized and authorized.
|
|
||||||
#[instruction]
|
#[instruction]
|
||||||
pub fn print_nft(
|
pub fn print_nft(
|
||||||
master_account: AccountWithMetadata,
|
master_account: AccountWithMetadata,
|
||||||
|
|||||||
@ -31,6 +31,7 @@ pub fn burn(
|
|||||||
name: _,
|
name: _,
|
||||||
metadata_id: _,
|
metadata_id: _,
|
||||||
total_supply,
|
total_supply,
|
||||||
|
mint_authority: _,
|
||||||
},
|
},
|
||||||
TokenHolding::Fungible {
|
TokenHolding::Fungible {
|
||||||
definition_id: _,
|
definition_id: _,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ pub mod initialize;
|
|||||||
pub mod mint;
|
pub mod mint;
|
||||||
pub mod new_definition;
|
pub mod new_definition;
|
||||||
pub mod print_nft;
|
pub mod print_nft;
|
||||||
|
pub mod set_authority;
|
||||||
pub mod transfer;
|
pub mod transfer;
|
||||||
|
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@ -1,26 +1,44 @@
|
|||||||
|
use lez_authority::Authority;
|
||||||
use nssa_core::{
|
use nssa_core::{
|
||||||
account::{Account, AccountWithMetadata, Data},
|
account::{Account, AccountWithMetadata, Data},
|
||||||
program::{AccountPostState, Claim, ProgramId},
|
program::{AccountPostState, Claim, ProgramId},
|
||||||
};
|
};
|
||||||
use token_core::{TokenDefinition, TokenHolding};
|
use token_core::{TokenDefinition, TokenHolding};
|
||||||
|
|
||||||
|
/// Mint new tokens to the holder's account.
|
||||||
|
///
|
||||||
|
/// Uses the `lez-authority` crate (RFP-001) to enforce that only the current
|
||||||
|
/// mint authority can mint additional supply.
|
||||||
|
///
|
||||||
|
/// Required accounts:
|
||||||
|
/// 1. Token Definition account (initialized, authorized by mint authority).
|
||||||
|
/// 2. Token Holding account (initialized, or uninitialized with holder authorization).
|
||||||
pub fn mint(
|
pub fn mint(
|
||||||
definition_account: AccountWithMetadata,
|
definition_account: AccountWithMetadata,
|
||||||
user_holding_account: AccountWithMetadata,
|
user_holding_account: AccountWithMetadata,
|
||||||
amount_to_mint: u128,
|
amount_to_mint: u128,
|
||||||
token_program_id: ProgramId,
|
token_program_id: ProgramId,
|
||||||
) -> Vec<AccountPostState> {
|
) -> Vec<AccountPostState> {
|
||||||
assert!(
|
|
||||||
definition_account.is_authorized,
|
|
||||||
"Definition authorization is missing"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
definition_account.account.program_owner, token_program_id,
|
definition_account.account.program_owner, token_program_id,
|
||||||
"Token definition must be owned by token program"
|
"Token definition must be owned by token program"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut definition = TokenDefinition::try_from(&definition_account.account.data)
|
let mut definition = TokenDefinition::try_from(&definition_account.account.data)
|
||||||
.expect("Token Definition account must be valid");
|
.expect("Definition account must be valid");
|
||||||
|
|
||||||
|
// Enforce mint authority via lez-authority (RFP-001)
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut holding = if user_holding_account.account == Account::default() {
|
let mut holding = if user_holding_account.account == Account::default() {
|
||||||
TokenHolding::zeroized_from_definition(definition_account.account_id, &definition)
|
TokenHolding::zeroized_from_definition(definition_account.account_id, &definition)
|
||||||
} else {
|
} else {
|
||||||
@ -36,30 +54,16 @@ pub fn mint(
|
|||||||
|
|
||||||
match (&mut definition, &mut holding) {
|
match (&mut definition, &mut holding) {
|
||||||
(
|
(
|
||||||
TokenDefinition::Fungible {
|
TokenDefinition::Fungible { total_supply, .. },
|
||||||
name: _,
|
TokenHolding::Fungible { balance, .. },
|
||||||
metadata_id: _,
|
|
||||||
total_supply,
|
|
||||||
},
|
|
||||||
TokenHolding::Fungible {
|
|
||||||
definition_id: _,
|
|
||||||
balance,
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
*balance = balance
|
*balance = balance
|
||||||
.checked_add(amount_to_mint)
|
.checked_add(amount_to_mint)
|
||||||
.expect("Balance overflow on minting");
|
.expect("Balance overflow on minting");
|
||||||
|
|
||||||
*total_supply = total_supply
|
*total_supply = total_supply
|
||||||
.checked_add(amount_to_mint)
|
.checked_add(amount_to_mint)
|
||||||
.expect("Total supply overflow");
|
.expect("Total supply overflow");
|
||||||
}
|
}
|
||||||
(
|
|
||||||
TokenDefinition::NonFungible { .. },
|
|
||||||
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. },
|
|
||||||
) => {
|
|
||||||
panic!("Cannot mint additional supply for Non-Fungible Tokens");
|
|
||||||
}
|
|
||||||
_ => panic!("Mismatched Token Definition and Token Holding types"),
|
_ => panic!("Mismatched Token Definition and Token Holding types"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ pub fn new_fungible_definition(
|
|||||||
name,
|
name,
|
||||||
total_supply,
|
total_supply,
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
};
|
};
|
||||||
let token_holding = TokenHolding::Fungible {
|
let token_holding = TokenHolding::Fungible {
|
||||||
definition_id: definition_target_account.account_id,
|
definition_id: definition_target_account.account_id,
|
||||||
@ -97,6 +98,7 @@ pub fn new_definition_with_metadata(
|
|||||||
name,
|
name,
|
||||||
total_supply,
|
total_supply,
|
||||||
metadata_id: Some(metadata_target_account.account_id),
|
metadata_id: Some(metadata_target_account.account_id),
|
||||||
|
mint_authority: None,
|
||||||
},
|
},
|
||||||
TokenHolding::Fungible {
|
TokenHolding::Fungible {
|
||||||
definition_id: definition_target_account.account_id,
|
definition_id: definition_target_account.account_id,
|
||||||
@ -142,3 +144,48 @@ pub fn new_definition_with_metadata(
|
|||||||
AccountPostState::new_claimed(metadata_target_account_post, Claim::Authorized),
|
AccountPostState::new_claimed(metadata_target_account_post, Claim::Authorized),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn new_fungible_definition_with_authority(
|
||||||
|
definition_account: nssa_core::account::AccountWithMetadata,
|
||||||
|
holding_account: nssa_core::account::AccountWithMetadata,
|
||||||
|
name: String,
|
||||||
|
total_supply: u128,
|
||||||
|
mint_authority: Option<nssa_core::account::AccountId>,
|
||||||
|
) -> Vec<nssa_core::program::AccountPostState> {
|
||||||
|
use nssa_core::{
|
||||||
|
account::Data,
|
||||||
|
program::{AccountPostState, Claim},
|
||||||
|
};
|
||||||
|
use token_core::{TokenDefinition, TokenHolding};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
definition_account.is_authorized,
|
||||||
|
"Definition authorization is missing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
holding_account.is_authorized,
|
||||||
|
"Holding authorization is missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let definition = TokenDefinition::Fungible {
|
||||||
|
name,
|
||||||
|
total_supply,
|
||||||
|
metadata_id: None,
|
||||||
|
mint_authority,
|
||||||
|
};
|
||||||
|
let holding = TokenHolding::Fungible {
|
||||||
|
definition_id: definition_account.account_id,
|
||||||
|
balance: total_supply,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut definition_post = definition_account.account;
|
||||||
|
definition_post.data = Data::from(&definition);
|
||||||
|
let mut holding_post = holding_account.account;
|
||||||
|
holding_post.data = Data::from(&holding);
|
||||||
|
|
||||||
|
vec![
|
||||||
|
AccountPostState::new_claimed(definition_post, Claim::Authorized),
|
||||||
|
AccountPostState::new_claimed(holding_post, Claim::Authorized),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
52
programs/token/src/set_authority.rs
Normal file
52
programs/token/src/set_authority.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
use lez_authority::Authority;
|
||||||
|
use nssa_core::{
|
||||||
|
account::{AccountId, AccountWithMetadata, Data},
|
||||||
|
program::AccountPostState,
|
||||||
|
};
|
||||||
|
use token_core::TokenDefinition;
|
||||||
|
|
||||||
|
/// Rotate or revoke the mint authority on a fungible token definition.
|
||||||
|
///
|
||||||
|
/// Uses the `lez-authority` crate (RFP-001) for standardised access control.
|
||||||
|
///
|
||||||
|
/// - `new_authority: Some(id)` — transfers authority to `id`. Previous authority
|
||||||
|
/// loses all minting rights immediately.
|
||||||
|
/// - `new_authority: None` — permanently renounces authority. Supply is fixed
|
||||||
|
/// from this point on. This operation is irreversible.
|
||||||
|
///
|
||||||
|
/// Required accounts:
|
||||||
|
/// 1. Token Definition account (initialized, authorized by current mint 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut definition_post = definition_account.account;
|
||||||
|
definition_post.data = Data::from(&definition);
|
||||||
|
|
||||||
|
vec![AccountPostState::new(definition_post)]
|
||||||
|
}
|
||||||
@ -42,6 +42,7 @@ impl AccountForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::init_supply(),
|
total_supply: BalanceForTests::init_supply(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(IdForTests::pool_definition_id()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -50,6 +51,24 @@ impl AccountForTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn definition_account_active_authority_unauthorized() -> AccountWithMetadata {
|
||||||
|
AccountWithMetadata {
|
||||||
|
account: Account {
|
||||||
|
program_owner: TOKEN_PROGRAM_ID,
|
||||||
|
balance: 0u128,
|
||||||
|
data: Data::from(&TokenDefinition::Fungible {
|
||||||
|
name: String::from("test"),
|
||||||
|
total_supply: BalanceForTests::init_supply(),
|
||||||
|
metadata_id: None,
|
||||||
|
mint_authority: Some(IdForTests::pool_definition_id()),
|
||||||
|
}),
|
||||||
|
nonce: Nonce(0),
|
||||||
|
},
|
||||||
|
is_authorized: false,
|
||||||
|
account_id: IdForTests::pool_definition_id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn definition_account_foreign_owner() -> AccountWithMetadata {
|
fn definition_account_foreign_owner() -> AccountWithMetadata {
|
||||||
AccountWithMetadata {
|
AccountWithMetadata {
|
||||||
account: Account {
|
account: Account {
|
||||||
@ -59,6 +78,7 @@ impl AccountForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::init_supply(),
|
total_supply: BalanceForTests::init_supply(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -76,6 +96,7 @@ impl AccountForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::init_supply(),
|
total_supply: BalanceForTests::init_supply(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -157,6 +178,7 @@ impl AccountForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::init_supply_burned(),
|
total_supply: BalanceForTests::init_supply_burned(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(IdForTests::pool_definition_id()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -238,6 +260,7 @@ impl AccountForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::init_supply_mint(),
|
total_supply: BalanceForTests::init_supply_mint(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: Some(IdForTests::pool_definition_id()),
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -328,6 +351,7 @@ impl AccountForTests {
|
|||||||
name: String::from("test"),
|
name: String::from("test"),
|
||||||
total_supply: BalanceForTests::init_supply(),
|
total_supply: BalanceForTests::init_supply(),
|
||||||
metadata_id: None,
|
metadata_id: None,
|
||||||
|
mint_authority: None,
|
||||||
}),
|
}),
|
||||||
nonce: Nonce(0),
|
nonce: Nonce(0),
|
||||||
},
|
},
|
||||||
@ -918,8 +942,11 @@ fn test_mint_not_valid_definition_account() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "Definition authorization is missing")]
|
#[should_panic(expected = "Renounced: authority has been permanently revoked")]
|
||||||
fn test_mint_missing_authorization() {
|
fn test_mint_missing_authorization() {
|
||||||
|
// mint_authority is None here, so the authority library correctly reports
|
||||||
|
// `Renounced` regardless of the is_authorized flag - there is no authority
|
||||||
|
// to satisfy in the first place.
|
||||||
let definition_account = AccountForTests::definition_account_without_auth();
|
let definition_account = AccountForTests::definition_account_without_auth();
|
||||||
let holding_account = AccountForTests::holding_same_definition_without_authorization();
|
let holding_account = AccountForTests::holding_same_definition_without_authorization();
|
||||||
let _post_states = mint(
|
let _post_states = mint(
|
||||||
@ -930,6 +957,21 @@ fn test_mint_missing_authorization() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "Unauthorized: caller is not the current authority")]
|
||||||
|
fn test_mint_fails_when_unauthorized_with_active_authority() {
|
||||||
|
// mint_authority is Some(...) here, but is_authorized is false, so the
|
||||||
|
// authority library must reject with `Unauthorized`, not `Renounced`.
|
||||||
|
let definition_account = AccountForTests::definition_account_active_authority_unauthorized();
|
||||||
|
let holding_account = AccountForTests::holding_same_definition_without_authorization();
|
||||||
|
let _post_states = mint(
|
||||||
|
definition_account,
|
||||||
|
holding_account,
|
||||||
|
BalanceForTests::mint_success(),
|
||||||
|
TOKEN_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "Token definition must be owned by token program")]
|
#[should_panic(expected = "Token definition must be owned by token program")]
|
||||||
fn test_mint_rejects_foreign_owned_definition() {
|
fn test_mint_rejects_foreign_owned_definition() {
|
||||||
|
|||||||
252
scripts/demo.sh
Normal file
252
scripts/demo.sh
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# LP-0013: Token Mint Authority — End-to-End Demo Script
|
||||||
|
#
|
||||||
|
# Demonstrates the full mint authority lifecycle:
|
||||||
|
# 1. Create a token WITH mint authority
|
||||||
|
# 2. Mint additional tokens (authority active)
|
||||||
|
# 3. Rotate authority to a new account
|
||||||
|
# 4. Revoke authority permanently
|
||||||
|
# 5. Verify mint fails after revocation
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - Bedrock, sequencer, and indexer must be running
|
||||||
|
# - Wallet binary built from logos-execution-zone
|
||||||
|
# - SEQUENCER_URL set (default: http://127.0.0.1:3040)
|
||||||
|
# - LEZ_WALLET_HOME_DIR set to wallet config directory
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# RISC0_DEV_MODE=1 bash scripts/demo.sh
|
||||||
|
#
|
||||||
|
# For real proof generation :
|
||||||
|
# RISC0_DEV_MODE=0 bash scripts/demo.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
SEQUENCER_URL="${SEQUENCER_URL:-http://127.0.0.1:3040}"
|
||||||
|
LEZ_WALLET_HOME_DIR="${LEZ_WALLET_HOME_DIR:-}"
|
||||||
|
WALLET_BIN="${WALLET_BIN:-}"
|
||||||
|
|
||||||
|
# Try to find wallet binary automatically
|
||||||
|
if [ -z "$WALLET_BIN" ]; then
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
# Look for logos-execution-zone sibling or parent
|
||||||
|
for candidate in \
|
||||||
|
"$REPO_ROOT/../logos-execution-zone/target/release/wallet" \
|
||||||
|
"$HOME/Desktop/LP-0013/logos/logos-execution-zone/target/release/wallet" \
|
||||||
|
"$HOME/logos/logos-execution-zone/target/release/wallet"; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
WALLET_BIN="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$WALLET_BIN" ] || [ ! -x "$WALLET_BIN" ]; then
|
||||||
|
echo "❌ wallet binary not found. Set WALLET_BIN=/path/to/wallet"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$LEZ_WALLET_HOME_DIR" ]; then
|
||||||
|
WALLET_DIR="$(dirname "$WALLET_BIN")"
|
||||||
|
for candidate in \
|
||||||
|
"$WALLET_DIR/../../../lez/wallet/configs/debug" \
|
||||||
|
"$HOME/Desktop/LP-0013/logos/logos-execution-zone/lez/wallet/configs/debug" \
|
||||||
|
"$HOME/logos/logos-execution-zone/lez/wallet/configs/debug"; do
|
||||||
|
if [ -d "$candidate" ]; then
|
||||||
|
LEZ_WALLET_HOME_DIR="$(cd "$candidate" && pwd)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
export LEZ_WALLET_HOME_DIR
|
||||||
|
export SEQUENCER_URL
|
||||||
|
|
||||||
|
WALLET="$WALLET_BIN"
|
||||||
|
|
||||||
|
# Helpers
|
||||||
|
|
||||||
|
green() { echo -e "\033[0;32m$*\033[0m"; }
|
||||||
|
yellow() { echo -e "\033[0;33m$*\033[0m"; }
|
||||||
|
red() { echo -e "\033[0;31m$*\033[0m"; }
|
||||||
|
header() { echo; echo "════════════════════════════════════════"; green "▶ $*"; echo "════════════════════════════════════════"; }
|
||||||
|
|
||||||
|
wallet() { "$WALLET" "$@"; }
|
||||||
|
|
||||||
|
extract_id() {
|
||||||
|
# Extract account_id from wallet output: "Public/Xxxx..." → "Xxxx..."
|
||||||
|
grep -oE 'Public/[A-Za-z0-9]+' | head -1 | sed 's/Public\///'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preflight
|
||||||
|
|
||||||
|
header "Preflight checks"
|
||||||
|
|
||||||
|
echo "SEQUENCER_URL: $SEQUENCER_URL"
|
||||||
|
echo "LEZ_WALLET_HOME_DIR: $LEZ_WALLET_HOME_DIR"
|
||||||
|
echo "WALLET_BIN: $WALLET_BIN"
|
||||||
|
echo "RISC0_DEV_MODE: ${RISC0_DEV_MODE:-0}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
wallet check-health
|
||||||
|
green "✅ Sequencer is healthy"
|
||||||
|
|
||||||
|
# Step 1: Create accounts
|
||||||
|
|
||||||
|
header "Step 1: Create demo accounts"
|
||||||
|
|
||||||
|
DEF_OUTPUT=$(wallet account new public --label "demo-def-$$" 2>&1)
|
||||||
|
echo "$DEF_OUTPUT"
|
||||||
|
DEF_ID=$(echo "$DEF_OUTPUT" | extract_id)
|
||||||
|
green "Definition account: $DEF_ID"
|
||||||
|
|
||||||
|
SUPPLY_OUTPUT=$(wallet account new public --label "demo-supply-$$" 2>&1)
|
||||||
|
echo "$SUPPLY_OUTPUT"
|
||||||
|
SUPPLY_ID=$(echo "$SUPPLY_OUTPUT" | extract_id)
|
||||||
|
green "Supply account: $SUPPLY_ID"
|
||||||
|
|
||||||
|
AUTH2_OUTPUT=$(wallet account new public --label "demo-auth2-$$" 2>&1)
|
||||||
|
echo "$AUTH2_OUTPUT"
|
||||||
|
AUTH2_ID=$(echo "$AUTH2_OUTPUT" | extract_id)
|
||||||
|
green "New authority account: $AUTH2_ID"
|
||||||
|
|
||||||
|
# Step 2: Create token WITH mint authority
|
||||||
|
|
||||||
|
header "Step 2: Create 'Gold' token with mint authority set to definition account"
|
||||||
|
|
||||||
|
TX=$(wallet token new-with-authority \
|
||||||
|
--definition-account-id "Public/$DEF_ID" \
|
||||||
|
--supply-account-id "Public/$SUPPLY_ID" \
|
||||||
|
--name "Gold" \
|
||||||
|
--total-supply 1000000 \
|
||||||
|
--mint-authority "$DEF_ID" 2>&1)
|
||||||
|
echo "$TX"
|
||||||
|
green "✅ Token created with mint_authority=$DEF_ID"
|
||||||
|
|
||||||
|
echo; yellow "Waiting for transaction to be included in block..."
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
echo; yellow "Verifying on-chain state..."
|
||||||
|
ACCOUNT_STATE=$(wallet account get --account-id "Public/$DEF_ID" 2>&1)
|
||||||
|
echo "$ACCOUNT_STATE"
|
||||||
|
|
||||||
|
if echo "$ACCOUNT_STATE" | grep -q "\"mint_authority\":\"$DEF_ID\""; then
|
||||||
|
green "✅ mint_authority correctly set to $DEF_ID"
|
||||||
|
else
|
||||||
|
red "❌ Unexpected account state after creation"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 3: Mint additional tokens
|
||||||
|
|
||||||
|
header "Step 3: Mint 500,000 additional tokens (authority is active)"
|
||||||
|
|
||||||
|
TX=$(wallet token mint \
|
||||||
|
--definition "Public/$DEF_ID" \
|
||||||
|
--holder "Public/$SUPPLY_ID" \
|
||||||
|
--amount 500000 2>&1)
|
||||||
|
echo "$TX"
|
||||||
|
green "✅ Mint transaction submitted"
|
||||||
|
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
ACCOUNT_STATE=$(wallet account get --account-id "Public/$DEF_ID" 2>&1)
|
||||||
|
echo "$ACCOUNT_STATE"
|
||||||
|
|
||||||
|
if echo "$ACCOUNT_STATE" | grep -q '"total_supply":1500000'; then
|
||||||
|
green "✅ total_supply correctly updated to 1,500,000"
|
||||||
|
else
|
||||||
|
yellow "⚠️ Supply may still be updating — check account state manually"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Rotate authority to new account
|
||||||
|
|
||||||
|
header "Step 4: Rotate mint authority to new account ($AUTH2_ID)"
|
||||||
|
|
||||||
|
TX=$(wallet token set-authority \
|
||||||
|
--definition-account-id "Public/$DEF_ID" \
|
||||||
|
--new-authority "$AUTH2_ID" 2>&1)
|
||||||
|
echo "$TX"
|
||||||
|
green "✅ Authority rotation submitted"
|
||||||
|
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
ACCOUNT_STATE=$(wallet account get --account-id "Public/$DEF_ID" 2>&1)
|
||||||
|
echo "$ACCOUNT_STATE"
|
||||||
|
|
||||||
|
if echo "$ACCOUNT_STATE" | grep -q "\"mint_authority\":\"$AUTH2_ID\""; then
|
||||||
|
green "✅ mint_authority correctly rotated to $AUTH2_ID"
|
||||||
|
else
|
||||||
|
yellow "⚠️ Authority may still be updating — check account state manually"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Revoke authority permanently
|
||||||
|
|
||||||
|
header "Step 5: Revoke mint authority permanently (supply is now fixed)"
|
||||||
|
|
||||||
|
TX=$(wallet token set-authority \
|
||||||
|
--definition-account-id "Public/$DEF_ID" \
|
||||||
|
--new-authority "none" 2>&1)
|
||||||
|
echo "$TX"
|
||||||
|
green "✅ Authority revocation submitted"
|
||||||
|
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
ACCOUNT_STATE=$(wallet account get --account-id "Public/$DEF_ID" 2>&1)
|
||||||
|
echo "$ACCOUNT_STATE"
|
||||||
|
|
||||||
|
if echo "$ACCOUNT_STATE" | grep -q '"mint_authority":null'; then
|
||||||
|
green "✅ mint_authority is null — supply permanently fixed"
|
||||||
|
else
|
||||||
|
yellow "⚠️ Revocation may still be processing — check account state manually"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 6: Verify mint fails after revocation
|
||||||
|
|
||||||
|
header "Step 6: Attempt mint after revocation (expected: transaction rejected by program)"
|
||||||
|
|
||||||
|
yellow "Submitting mint transaction — sequencer will reject it..."
|
||||||
|
TX=$(wallet token mint \
|
||||||
|
--definition "Public/$DEF_ID" \
|
||||||
|
--holder "Public/$SUPPLY_ID" \
|
||||||
|
--amount 100000 2>&1 || true)
|
||||||
|
echo "$TX"
|
||||||
|
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
FINAL_STATE=$(wallet account get --account-id "Public/$DEF_ID" 2>&1)
|
||||||
|
echo "$FINAL_STATE"
|
||||||
|
|
||||||
|
if echo "$FINAL_STATE" | grep -q '"total_supply":1500000'; then
|
||||||
|
green "✅ Supply unchanged at 1,500,000 — mint correctly rejected after revocation"
|
||||||
|
elif echo "$FINAL_STATE" | grep -q '"mint_authority":null'; then
|
||||||
|
green "✅ Authority is null — mint was rejected (verify supply manually)"
|
||||||
|
else
|
||||||
|
yellow "⚠️ Check sequencer logs for: 'Mint authority has been revoked; supply is fixed'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
header "Demo Complete"
|
||||||
|
|
||||||
|
green "Full lifecycle demonstrated:"
|
||||||
|
echo " ✅ Token created with mint_authority=$DEF_ID"
|
||||||
|
echo " ✅ 500,000 tokens minted (total_supply: 1,000,000 → 1,500,000)"
|
||||||
|
echo " ✅ Authority rotated to $AUTH2_ID"
|
||||||
|
echo " ✅ Authority permanently revoked (mint_authority: null)"
|
||||||
|
echo " ✅ Mint rejected after revocation"
|
||||||
|
echo
|
||||||
|
green "Both repos:"
|
||||||
|
echo " lez-programs: https://github.com/youthisguy/lez-programs"
|
||||||
|
echo " logos-execution-zone: https://github.com/youthisguy/logos-execution-zone"
|
||||||
|
echo
|
||||||
|
if [ "${RISC0_DEV_MODE:-0}" = "0" ]; then
|
||||||
|
green "🔐 RISC0_DEV_MODE=0 — real ZK proofs were generated"
|
||||||
|
else
|
||||||
|
yellow "⚠️ RISC0_DEV_MODE=1 — dev mode (no real proofs). Re-run with RISC0_DEV_MODE=0 for submission."
|
||||||
|
fi
|
||||||
371
token.idl.json
Normal file
371
token.idl.json
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.0",
|
||||||
|
"name": "token",
|
||||||
|
"instructions": [
|
||||||
|
{
|
||||||
|
"name": "transfer",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "sender",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recipient",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "amount_to_transfer",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_fungible_definition",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "holding_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_supply",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_fungible_definition_with_authority",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "holding_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_supply",
|
||||||
|
"type": "u128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_authority",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "new_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_definition_with_metadata",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "holding_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata_target_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "new_definition",
|
||||||
|
"type": {
|
||||||
|
"defined": "NewTokenDefinition"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata",
|
||||||
|
"type": {
|
||||||
|
"defined": "Box"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "initialize_account",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "account_to_initialize",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "burn",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_holding_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "amount_to_burn",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "definition_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_holding_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "amount_to_mint",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "print_nft",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "master_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "printed_account",
|
||||||
|
"writable": false,
|
||||||
|
"signer": false,
|
||||||
|
"init": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"name": "TokenDefinition",
|
||||||
|
"type": {
|
||||||
|
"kind": "enum",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "Fungible",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_supply",
|
||||||
|
"type": "u128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata_id",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mint_authority",
|
||||||
|
"type": {
|
||||||
|
"option": "account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NonFungible",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "printable_supply",
|
||||||
|
"type": "u128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata_id",
|
||||||
|
"type": "account_id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TokenHolding",
|
||||||
|
"type": {
|
||||||
|
"kind": "enum",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "Fungible",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "definition_id",
|
||||||
|
"type": "account_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "balance",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NftMaster",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "definition_id",
|
||||||
|
"type": "account_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "print_balance",
|
||||||
|
"type": "u128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NftPrintedCopy",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "definition_id",
|
||||||
|
"type": "account_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "owned",
|
||||||
|
"type": "bool"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TokenMetadata",
|
||||||
|
"type": {
|
||||||
|
"kind": "struct",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "definition_id",
|
||||||
|
"type": "account_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard",
|
||||||
|
"type": {
|
||||||
|
"defined": "MetadataStandard"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uri",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "creators",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "primary_sale_date",
|
||||||
|
"type": "u64"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"name": "MetadataStandard",
|
||||||
|
"kind": "enum",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "Simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Expanded"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"instruction_type": "token_core::Instruction"
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user