From 2d358c7d85771e116bdcf76e863afd3b904fbb01 Mon Sep 17 00:00:00 2001 From: Jimmy Claw Date: Mon, 9 Mar 2026 19:42:14 +0000 Subject: [PATCH 1/7] docs: add zero-to-hero tutorial --- docs/tutorial.md | 760 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 760 insertions(+) create mode 100644 docs/tutorial.md diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 00000000..db852d8a --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,760 @@ +# Zero to Hero: Building Your First LEZ Program with SPEL + +This tutorial walks you through building a **counter program** from scratch using the SPEL framework. By the end, you'll have a deployed on-chain program with increment and get_count instructions, and understand the full build-deploy-transact lifecycle. + +We reference [logos-co/lez-multisig](https://github.com/logos-co/lez-multisig) as a real-world example throughout. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Step 1: Scaffold the Project](#step-1-scaffold-the-project) +- [Step 2: Define Your State](#step-2-define-your-state) +- [Step 3: Write Instructions](#step-3-write-instructions) +- [Step 4: Set Up IDL Generation](#step-4-set-up-idl-generation) +- [Step 5: Set Up the CLI Wrapper](#step-5-set-up-the-cli-wrapper) +- [Step 6: Build and Generate IDL](#step-6-build-and-generate-idl) +- [Step 7: Deploy](#step-7-deploy) +- [Step 8: Interact with Your Program](#step-8-interact-with-your-program) +- [Step 9: Register in SPELbook](#step-9-register-in-spelbook) +- [Concepts Deep Dive](#concepts-deep-dive) + - [How the Macro Works](#how-the-macro-works) + - [Account Validation](#account-validation) + - [PDA Derivation](#pda-derivation) + - [External Instruction Enums](#external-instruction-enums) + - [Variable-Length Accounts](#variable-length-accounts) + - [Chained Calls](#chained-calls) + - [Client Code Generation](#client-code-generation) +- [Next Steps](#next-steps) + +--- + +## Prerequisites + +Before you begin, make sure you have: + +- **Rust** with the nightly toolchain (`rustup install nightly`) +- **RISC Zero toolchain** — [install instructions](https://dev.risczero.com/api/zkvm/install) +- **NSSA wallet CLI** (`wallet` binary) — for account creation and transaction signing +- A **running sequencer** — the network node that accepts transactions +- **lez-cli** installed: + +```bash +# From the SPEL repo +cargo install --path lez-cli +``` + +--- + +## Step 1: Scaffold the Project + +Use `lez-cli init` to create a new project: + +```bash +lez-cli init my-counter +cd my-counter +``` + +This generates: + +``` +my-counter/ +├── Cargo.toml # Workspace +├── Makefile # build, idl, cli, deploy targets +├── .gitignore +├── README.md +├── my_counter_core/ # Shared types +│ ├── Cargo.toml +│ └── src/lib.rs +├── methods/ +│ ├── Cargo.toml +│ ├── build.rs +│ ├── src/lib.rs +│ └── guest/ # On-chain program +│ ├── Cargo.toml +│ └── src/bin/my_counter.rs # ← Your program logic goes here +└── examples/ + ├── Cargo.toml + └── src/bin/ + ├── generate_idl.rs # IDL generator (one-liner) + └── my_counter_cli.rs # CLI wrapper (three lines) +``` + +The scaffold includes a working example with `initialize` and `do_something` instructions. We'll replace these with our counter logic. + +> **Real-world example:** The [lez-multisig](https://github.com/logos-co/lez-multisig) program follows this exact structure, with a `multisig_core` crate for shared types and a guest binary for the on-chain program. + +--- + +## Step 2: Define Your State + +Edit `my_counter_core/src/lib.rs` to define your counter state: + +```rust +use serde::{Deserialize, Serialize}; + +/// The counter state stored on-chain. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CounterState { + /// The current count value. + pub count: u64, + /// The owner who can increment. + pub owner: [u8; 32], +} +``` + +This state struct lives in the `_core` crate so it can be shared between the on-chain guest program and any off-chain tools. It needs to be serializable since it's stored in account data. + +> **Real-world example:** In lez-multisig, the `multisig_core` crate defines the `MultisigState` struct with fields like `threshold`, `members`, and `proposal_index`. + +--- + +## Step 3: Write Instructions + +This is the core of your program. Edit `methods/guest/src/bin/my_counter.rs`: + +```rust +#![no_main] + +use nssa_core::account::AccountWithMetadata; +use nssa_core::program::AccountPostState; +use lez_framework::prelude::*; + +risc0_zkvm::guest::entry!(main); + +#[lez_program] +mod my_counter { + #[allow(unused_imports)] + use super::*; + + /// Initialize the counter with an owner. + /// + /// Creates a new PDA account derived from the literal seed "counter". + /// The owner is the signer who can later increment the counter. + #[instruction] + pub fn initialize( + #[account(init, pda = literal("counter"))] + counter: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + ) -> LezResult { + // Serialize initial state into the account data + let state = my_counter_core::CounterState { + count: 0, + owner: *owner.account_id.value(), + }; + let data = borsh::to_vec(&state).map_err(|e| LezError::SerializationError { + message: e.to_string(), + })?; + + let mut new_account = counter.account.clone(); + new_account.data = data.try_into().unwrap(); + + Ok(LezOutput::states_only(vec![ + AccountPostState::new_claimed(new_account), + AccountPostState::new(owner.account.clone()), + ])) + } + + /// Increment the counter by a given amount. + /// + /// Only the owner can increment. The counter account is a PDA + /// derived from the literal seed "counter". + #[instruction] + pub fn increment( + #[account(mut, pda = literal("counter"))] + counter: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + amount: u64, + ) -> LezResult { + // Deserialize current state + let mut state: my_counter_core::CounterState = + borsh::from_slice(&counter.account.data).map_err(|e| { + LezError::DeserializationError { + account_index: 0, + message: e.to_string(), + } + })?; + + // Verify the signer is the owner + if *owner.account_id.value() != state.owner { + return Err(LezError::Unauthorized { + message: "Only the owner can increment".to_string(), + }); + } + + // Increment with overflow check + state.count = state.count.checked_add(amount).ok_or(LezError::Overflow { + operation: "counter increment".to_string(), + })?; + + // Serialize updated state + let data = borsh::to_vec(&state).map_err(|e| LezError::SerializationError { + message: e.to_string(), + })?; + let mut updated = counter.account.clone(); + updated.data = data.try_into().unwrap(); + + Ok(LezOutput::states_only(vec![ + AccountPostState::new(updated), + AccountPostState::new(owner.account.clone()), + ])) + } + + /// Get the current count value. + /// + /// This is a read-only instruction — it returns the state unchanged. + /// The count is embedded in the transaction output for off-chain reading. + #[instruction] + pub fn get_count( + #[account(pda = literal("counter"))] + counter: AccountWithMetadata, + ) -> LezResult { + let state: my_counter_core::CounterState = + borsh::from_slice(&counter.account.data).map_err(|e| { + LezError::DeserializationError { + account_index: 0, + message: e.to_string(), + } + })?; + + // Return account unchanged (read-only) + Ok(LezOutput::states_only(vec![ + AccountPostState::new(counter.account.clone()), + ])) + } +} +``` + +Let's break down what's happening: + +### Key concepts + +1. **`#[lez_program]`** — wraps your module and generates the guest `main()`, instruction dispatch, and IDL. + +2. **`#[instruction]`** — marks each function as an on-chain instruction. The function name becomes a CLI subcommand (e.g., `increment` → `lez-cli increment`). + +3. **`#[account(init, pda = literal("counter"))]`** — the counter account is a PDA (Program Derived Address) derived from the string `"counter"` and the program ID. The `init` constraint means this account must not already exist. + +4. **`#[account(signer)]`** — the owner must sign the transaction. The framework automatically checks `is_authorized` before your handler runs. + +5. **`#[account(mut, pda = literal("counter"))]`** — the counter account is writable (its state will change) and is a PDA. + +6. **`AccountPostState::new_claimed(account)`** — claims a new account (used with `init`). + +7. **`AccountPostState::new(account)`** — updates an existing account. + +> **Real-world example:** The lez-multisig program has instructions like `create` (with `init` + multi-seed PDA), `create_proposal`, and `approve` (with signer checks for members). + +--- + +## Step 4: Set Up IDL Generation + +The scaffold already created `examples/src/bin/generate_idl.rs`. Make sure it points to your program: + +```rust +/// Generate IDL JSON for the my-counter program. +lez_framework::generate_idl!("../methods/guest/src/bin/my_counter.rs"); +``` + +This reads your program source at compile time and generates a `main()` that prints the complete IDL as JSON. The IDL describes all instructions, their accounts, arguments, PDA seeds, and types. + +--- + +## Step 5: Set Up the CLI Wrapper + +The scaffold already created `examples/src/bin/my_counter_cli.rs`: + +```rust +#[tokio::main] +async fn main() { + lez_cli::run().await; +} +``` + +That's it — three lines. The CLI reads the IDL at runtime and auto-generates subcommands for every instruction in your program. + +--- + +## Step 6: Build and Generate IDL + +```bash +# Build the guest binary (compiles for RISC Zero zkVM) +make build + +# Generate the IDL from your program annotations +make idl +``` + +The `make idl` command runs `cargo run --bin generate_idl` and writes `my-counter-idl.json`. Let's look at what it generates: + +```json +{ + "version": "0.1.0", + "name": "my_counter", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "counter", + "writable": true, + "signer": false, + "init": true, + "pda": { + "seeds": [ + { "kind": "const", "value": "counter" } + ] + } + }, + { + "name": "owner", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [] + }, + { + "name": "increment", + "accounts": [ + { + "name": "counter", + "writable": true, + "signer": false, + "init": false, + "pda": { + "seeds": [ + { "kind": "const", "value": "counter" } + ] + } + }, + { + "name": "owner", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { "name": "amount", "type": "u64" } + ] + }, + { + "name": "get_count", + "accounts": [ + { + "name": "counter", + "writable": false, + "signer": false, + "init": false, + "pda": { + "seeds": [ + { "kind": "const", "value": "counter" } + ] + } + } + ], + "args": [] + } + ] +} +``` + +Notice: +- The `counter` account has `"pda"` with a `"const"` seed — the CLI will compute this automatically +- The `owner` account has `"signer": true` — the CLI will handle wallet signing +- `init: true` on the first instruction's counter account — the CLI knows this is a new account +- `amount` is the only instruction argument — everything else is an account + +--- + +## Step 7: Deploy + +First, set up your accounts and deploy the program: + +```bash +# Create a signer account in your wallet +make setup + +# Deploy the program binary to the sequencer +make deploy + +# Verify the deployment — prints the ProgramId +make inspect +``` + +The `make inspect` command shows your program's ID: + +``` +📦 methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin + ProgramId (decimal): 12345,67890,... + ProgramId (hex): 00003039,00010932,... + ImageID (hex bytes): 3930000032920100... +``` + +Save the hex ImageID — you'll need it for CLI commands. + +--- + +## Step 8: Interact with Your Program + +### See available commands + +```bash +make cli ARGS="--help" +``` + +Output: + +``` +🔧 my_counter v0.1.0 — IDL-driven CLI + +USAGE: + my_counter_cli [OPTIONS] [ARGS] + +COMMANDS: + inspect [FILE...] Print ProgramId for ELF binary(ies) + idl Print IDL information + initialize --owner-account + increment --amount --owner-account + get-count +``` + +Notice how the CLI auto-generated commands from your IDL: +- PDA accounts (`counter`) are not listed as arguments — they're computed automatically +- Instruction arguments (`amount`) are typed +- Account arguments (`owner`) expect base58 or hex + +### Initialize the counter + +```bash +make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin \ + initialize --owner-account " +``` + +The CLI will: +1. Load the program binary to get the ProgramId +2. Compute the `counter` PDA from the seed `"counter"` + ProgramId +3. Fetch the nonce for the signer account from the wallet +4. Build and sign the transaction +5. Submit to the sequencer +6. Wait for confirmation + +### Increment the counter + +```bash +make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin \ + increment --amount 5 --owner-account " +``` + +### Use `--program-id` to skip loading the binary + +If you know the program ID, you can skip loading the binary: + +```bash +make cli ARGS="--program-id <64-CHAR-HEX> \ + increment --amount 10 --owner-account " +``` + +### Dry run (no submission) + +Add `--dry-run` to see what would be submitted without actually sending: + +```bash +make cli ARGS="--dry-run -p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin \ + increment --amount 5 --owner-account " +``` + +This prints the parsed arguments, serialized instruction data, and account IDs without submitting. + +### Compute the counter PDA manually + +```bash +make cli ARGS="--program-id <64-CHAR-HEX> pda counter" +``` + +This prints the base58 AccountId of the counter PDA. + +--- + +## Step 9: Register in SPELbook + +TODO: verify — SPELbook registration process is not yet documented in the codebase. + +Once your program is deployed and working, you can register it in SPELbook to make it discoverable by other developers and programs. + +--- + +## Concepts Deep Dive + +### How the Macro Works + +The `#[lez_program]` macro transforms your module at compile time. Here's what it generates for our counter program: + +**1. Instruction Enum** + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Instruction { + Initialize, + Increment { amount: u64 }, + GetCount, +} +``` + +Variant names are PascalCase conversions of function names. Only non-account parameters become fields. + +**2. Main Function** (cfg-gated: only in zkVM guest builds, not in tests) + +```rust +fn main() { + // Read inputs from zkVM host + let (ProgramInput { pre_states, instruction }, instruction_words) + = read_nssa_inputs::(); + + // Dispatch to handler + let result = match instruction { + Instruction::Initialize => { + let [counter, owner] = ...; // destructure pre_states + my_counter::__validate_initialize(&[counter.clone(), owner.clone()])?; + my_counter::initialize(counter, owner) + } + Instruction::Increment { amount } => { + let [counter, owner] = ...; + my_counter::__validate_increment(&[counter.clone(), owner.clone()])?; + my_counter::increment(counter, owner, amount) + } + Instruction::GetCount => { + let [counter] = ...; + my_counter::get_count(counter) + } + }; + + // Write outputs + write_nssa_outputs_with_chained_call(...); +} +``` + +**3. Validation Functions** + +```rust +pub fn __validate_initialize(accounts: &[AccountWithMetadata]) -> Result<(), LezError> { + // init check: counter must be default + if accounts[0].account != Account::default() { + return Err(LezError::AccountAlreadyInitialized { account_index: 0 }); + } + // signer check: owner must be authorized + if !accounts[1].is_authorized { + return Err(LezError::Unauthorized { + message: "Account 'owner' (index 1) must be a signer".to_string(), + }); + } + Ok(()) +} +``` + +**4. IDL Constants** + +```rust +pub const PROGRAM_IDL_JSON: &str = r#"{"version":"0.1.0","name":"my_counter",...}"#; +pub fn __program_idl() -> LezIdl { ... } +``` + +### Account Validation + +The framework generates automatic validation checks that run before your handler: + +| Attribute | Check | Error | +|-----------|-------|-------| +| `signer` | `is_authorized == true` | `LezError::Unauthorized` | +| `init` | `account == Account::default()` | `LezError::AccountAlreadyInitialized` | + +These checks are generated per-instruction. If an instruction has no `signer` or `init` accounts, no validation function is generated. + +Validation runs in declaration order: if both `init` and `signer` checks fail, the `init` check (which comes first in the generated code) will be the reported error. + +### PDA Derivation + +PDAs (Program Derived Addresses) are deterministic account addresses computed from a program ID and seeds. They allow programs to "own" accounts without needing a private key. + +**How it works:** + +1. Each seed is converted to 32 bytes (zero-padded for strings) +2. Single seed: used directly as `PdaSeed` +3. Multiple seeds: combined via `SHA-256(seed1_32 || seed2_32 || ...)` +4. Final address: `AccountId::from((program_id, &PdaSeed::new(combined)))` + +**Seed types:** + +```rust +// Constant string — always the same +#[account(pda = literal("counter"))] + +// Another account's ID — PDA depends on which account is passed +#[account(pda = account("user"))] + +// Instruction argument — PDA depends on the argument value +#[account(pda = arg("create_key"))] + +// Multiple seeds — combined via SHA-256 +#[account(pda = [literal("vault"), account("user")])] +#[account(pda = [literal("proposal"), arg("proposal_index")])] +``` + +> **Real-world example:** In lez-multisig, the multisig state PDA uses two seeds: +> ```rust +> #[account(init, pda = [literal("multisig_state__"), arg("create_key")])] +> ``` +> This allows multiple independent multisig instances, each with a unique `create_key`. + +### External Instruction Enums + +For programs where the `Instruction` enum needs to be shared between the on-chain guest and off-chain tools (e.g., for FFI code generation with correct borsh serialization), you can define it in a shared core crate: + +```rust +// In multisig_core/src/lib.rs +#[derive(Debug, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub enum Instruction { + Create { create_key: [u8; 32], threshold: u64, members: Vec<[u8; 32]> }, + Approve { proposal_id: u64 }, + // ... +} +``` + +Then reference it in the program: + +```rust +#[lez_program(instruction = "multisig_core::Instruction")] +mod multisig { + // The macro uses multisig_core::Instruction instead of generating one + // ... +} +``` + +The IDL will include `"instruction_type": "multisig_core::Instruction"`, which tells `lez-client-gen` to import and use the shared type in generated FFI code. + +### Variable-Length Accounts + +Some instructions need a variable number of accounts. Use `Vec`: + +```rust +#[instruction] +pub fn multi_approve( + #[account(mut, pda = literal("state"))] + state: AccountWithMetadata, + #[account(signer)] + members: Vec, +) -> LezResult { + // members can contain 0, 1, 2, ... accounts + for member in &members { + // validate each member + } + // ... +} +``` + +In the CLI, pass rest accounts as a comma-separated list: + +```bash +lez-cli ... multi-approve --members-account "addr1,addr2,addr3" +``` + +Rest accounts are always optional (0 entries is valid). The macro splits `pre_states` into fixed accounts (before the rest) and the variadic tail. + +### Chained Calls + +Instructions can trigger calls to other programs by returning `ChainedCall`s: + +```rust +#[instruction] +pub fn transfer_and_notify( + // ... accounts ... + amount: u64, +) -> LezResult { + // ... transfer logic ... + + let chained_call = ChainedCall { + // ... target program and instruction data ... + }; + + Ok(LezOutput::with_chained_calls( + vec![/* post states */], + vec![chained_call], + )) +} +``` + +### Client Code Generation + +For integrating LEZ programs into applications (e.g., a C++/Qt desktop app), use `lez-client-gen` to generate typed bindings: + +```bash +lez-client-gen --idl my-counter-idl.json --out-dir generated/ +``` + +This produces three files: + +1. **`my_counter_client.rs`** — Async Rust client with typed methods +2. **`my_counter_ffi.rs`** — C FFI (`extern "C"` functions accepting JSON) +3. **`my_counter.h`** — C header file + +**Using the C FFI from C++/Qt:** + +```cpp +#include "my_counter.h" +#include +#include + +// Call the increment instruction +QJsonObject args; +args["wallet_path"] = "/path/to/wallet"; +args["program_id_hex"] = "abc123..."; +args["amount"] = 5; +args["owner"] = "base58-account-id"; + +QByteArray json = QJsonDocument(args).toJson(); +char* result = my_counter_increment(json.constData()); + +// Parse result +QJsonDocument resultDoc = QJsonDocument::fromJson(result); +bool success = resultDoc.object()["success"].toBool(); +QString txHash = resultDoc.object()["tx_hash"].toString(); + +my_counter_free_string(result); +``` + +Build the FFI as a shared library: + +```bash +cargo build --release --lib +# Produces libmy_counter.so / libmy_counter.dylib +``` + +--- + +## Next Steps + +- **Read the [Reference](reference.md)** for complete API documentation +- **Study [lez-multisig](https://github.com/logos-co/lez-multisig)** for a production-quality example with multi-seed PDAs, variable-length accounts, and external instruction enums +- **Generate client code** with `lez-client-gen` for integrating your program into applications +- **Write tests** — the `#[cfg(not(test))]` gate on `main()` means your handlers are directly callable in host-side tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initialize() { + let acc = AccountWithMetadata { + account_id: AccountId::new([0u8; 32]), + account: Account::default(), + is_authorized: true, + }; + let result = my_counter::initialize(acc.clone(), acc.clone()); + assert!(result.is_ok()); + } +} +``` From 800b563b2d0781538e6fc4bb88d3961a731eca8c Mon Sep 17 00:00:00 2001 From: Jimmy Claw Date: Mon, 9 Mar 2026 20:19:45 +0000 Subject: [PATCH 2/7] docs: add comprehensive reference documentation Co-Authored-By: Claude Opus 4.6 --- docs/reference.md | 1411 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1411 insertions(+) create mode 100644 docs/reference.md diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 00000000..8865e61b --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,1411 @@ +# SPEL Framework Reference + +Comprehensive API reference for the SPEL framework (`logos-co/spel`). This document covers every macro, type, CLI command, IDL schema, and code generation feature. + +For a guided walkthrough, see the [Tutorial](tutorial.md). + +## Table of Contents + +- [Macros](#macros) + - [`#[lez_program]`](#lez_program) + - [`#[instruction]`](#instruction) + - [`generate_idl!`](#generate_idl) +- [Types (lez-framework-core)](#types-lez-framework-core) + - [`LezOutput`](#lezoutput) + - [`LezResult`](#lezresult) + - [`LezError`](#lezerror) + - [`AccountConstraint`](#accountconstraint) + - [`InstructionMeta` / `AccountMeta` / `ArgMeta`](#instructionmeta--accountmeta--argmeta) + - [Re-exported nssa_core types](#re-exported-nssa_core-types) +- [CLI (lez-cli)](#cli-lez-cli) + - [Global Options](#global-options) + - [`init`](#init) + - [`inspect`](#inspect) + - [`idl`](#idl-command) + - [`pda` (IDL mode)](#pda-idl-mode) + - [`pda` (raw mode)](#pda-raw-mode) + - [Instruction Execution](#instruction-execution) + - [Type Format Table](#type-format-table) +- [IDL Format](#idl-format) + - [Top-Level Schema](#top-level-schema) + - [`instructions`](#instructions) + - [`accounts` (in instructions)](#accounts-in-instructions) + - [`args`](#args) + - [`pda`](#pda) + - [IDL Types](#idl-types) + - [`accounts` (top-level)](#accounts-top-level) + - [`types`](#types-section) + - [`errors`](#errors-section) + - [Discriminators](#discriminators) + - [`spec` and `metadata`](#spec-and-metadata) + - [`instruction_type`](#instruction_type) + - [Full Example IDL](#full-example-idl) +- [Client Code Generation (lez-client-gen)](#client-code-generation-lez-client-gen) + - [CLI Usage](#lez-client-gen-cli-usage) + - [Library API](#library-api) + - [Generated Rust Client](#generated-rust-client) + - [Generated C FFI](#generated-c-ffi) + - [Generated C Header](#generated-c-header) + - [Using from C++/Qt](#using-from-cqt) +- [Validation](#validation) + - [Generated Validation Functions](#generated-validation-functions) + - [Validation Helpers](#validation-helpers) + +--- + +## Macros + +### `#[lez_program]` + +**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) + +Attribute macro applied to a module. Transforms a module of `#[instruction]` functions into a complete LEZ guest binary with dispatch, validation, and IDL generation. + +#### Syntax + +```rust +#[lez_program] +mod my_program { + #[instruction] + pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } +} +``` + +With external instruction enum: + +```rust +#[lez_program(instruction = "my_crate::Instruction")] +mod my_program { + #[instruction] + pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } +} +``` + +#### Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `instruction` | `"path::to::Enum"` | Optional. External instruction enum path. When set, the macro imports this type instead of generating its own `Instruction` enum. Used when the enum must be shared between on-chain and off-chain code (e.g., for correct borsh serialization in FFI). | + +#### What It Generates + +1. **`Instruction` enum** — a `#[derive(Debug, Clone, Serialize, Deserialize)]` enum with one variant per `#[instruction]` function. Variant names are PascalCase conversions of function names. Only non-account parameters become enum fields. Skipped if `instruction = "..."` attribute is set. + +2. **`fn main()`** — the zkVM guest entry point (gated by `#[cfg(not(test))]`). Reads `ProgramInput` from the host, dispatches to the correct handler via `match`, and writes outputs via `write_nssa_outputs_with_chained_call`. Account destructuring from `pre_states` is generated per-instruction. + +3. **Validation functions** — one `__validate_{fn_name}()` function per instruction that has `signer` or `init` constraints. These run before the handler and return `LezError` on failure. + +4. **`PROGRAM_IDL_JSON`** — a `pub const &str` containing the complete IDL as JSON. Available at compile time in any build target. + +5. **`__program_idl()`** — a function returning a constructed `LezIdl` struct (with discriminators, execution metadata, etc.). + +#### Constraints + +- The module **must have a body** (not `mod foo;`). +- The module must contain **at least one** `#[instruction]` function. +- Instruction functions **cannot have `self`** parameters. +- Account parameters must be typed `AccountWithMetadata` or `Vec`. + +#### Example + +```rust +use lez_framework::prelude::*; +use nssa_core::account::AccountWithMetadata; +use nssa_core::program::AccountPostState; + +#[lez_program] +mod treasury { + use super::*; + + #[instruction] + pub fn deposit( + #[account(mut, pda = literal("vault"))] + vault: AccountWithMetadata, + #[account(signer)] + depositor: AccountWithMetadata, + amount: u128, + ) -> LezResult { + // ... business logic ... + Ok(LezOutput::states_only(vec![ + AccountPostState::new(vault.account.clone()), + AccountPostState::new(depositor.account.clone()), + ])) + } +} +``` + +This generates: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Instruction { + Deposit { amount: u128 }, +} +``` + +--- + +### `#[instruction]` + +**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) + +Marker attribute for functions inside an `#[lez_program]` module. This attribute is processed by `#[lez_program]` — it is a no-op when used standalone. + +#### Function Signature Requirements + +```rust +#[instruction] +pub fn instruction_name( + // Account parameters — must come first + #[account(/* constraints */)] + account_name: AccountWithMetadata, + + // Variable-length accounts (at most one, must be last account param) + #[account(/* constraints */)] + rest_accounts: Vec, + + // Instruction arguments — after all account params + arg1: u64, + arg2: String, +) -> LezResult { + // ... +} +``` + +- **Return type** must be `LezResult` (alias for `Result`). +- **Account parameters** are identified by type: `AccountWithMetadata` for single accounts, `Vec` for variable-length. +- **All other parameters** are instruction arguments and become fields in the generated `Instruction` enum variant. +- Function names use `snake_case`; generated enum variants use `PascalCase`. + +#### Account Constraint Attributes + +Applied via `#[account(...)]` on account parameters: + +| Constraint | Syntax | Description | +|------------|--------|-------------| +| `signer` | `#[account(signer)]` | Requires `is_authorized == true`. Generates a runtime check before the handler runs. | +| `init` | `#[account(init)]` | Account must be uninitialized (`== Account::default()`). **Implies `mut`**. Used with `AccountPostState::new_claimed()`. | +| `mut` | `#[account(mut)]` | Account is writable. Sets `writable: true` in the IDL. | +| `owner` | `#[account(owner = EXPR)]` | Account must be owned by the given program ID. The expression should resolve to `[u8; 32]`. | +| `pda` | `#[account(pda = SEED)]` | Account address is a PDA derived from the program ID and seed(s). See [PDA Seeds](#pda-seeds) below. Sets the `pda` field in the IDL. | +| `rest` | _(implicit)_ | Not an explicit attribute. When the type is `Vec`, the account is treated as variable-length (`rest: true` in the IDL). | + +Constraints can be combined: + +```rust +#[account(init, signer, pda = literal("state"))] +state: AccountWithMetadata, + +#[account(mut, owner = TOKEN_PROGRAM_ID)] +token_account: AccountWithMetadata, +``` + +#### PDA Seeds + +The `pda` attribute specifies how the account address is derived. Seed types: + +| Seed Type | Syntax | Description | +|-----------|--------|-------------| +| `const` | `literal("string")` or `seed_const("string")` | Constant string, UTF-8 encoded and zero-padded to 32 bytes. Aliases: `const(...)`, `r#const(...)`, `seed_const(...)`, `literal(...)`. | +| `account` | `account("other_account_name")` | Uses another account's 32-byte ID as the seed. | +| `arg` | `arg("argument_name")` | Uses an instruction argument's value as the seed. | + +**Single seed:** + +```rust +#[account(pda = literal("my_state"))] +``` + +**Multiple seeds** (array syntax): + +```rust +#[account(pda = [literal("multisig_state__"), arg("create_key")])] +#[account(pda = [literal("vault"), account("user")])] +#[account(pda = [literal("holding"), account("token"), account("user")])] +``` + +**PDA derivation logic:** +- Each seed is resolved to 32 bytes (strings are zero-padded) +- Single seed: used directly as `PdaSeed` +- Multiple seeds: combined via `SHA-256(seed1_32 || seed2_32 || ...)` into a single 32-byte seed +- Final address: `AccountId::from((program_id, &PdaSeed::new(combined)))` + +--- + +### `generate_idl!` + +**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) + +Proc macro that reads a Rust source file at compile time, finds the `#[lez_program]` module, and generates a `fn main()` that prints the complete IDL as JSON. + +#### Syntax + +```rust +lez_framework::generate_idl!("path/to/program.rs"); +``` + +The path is resolved relative to `CARGO_MANIFEST_DIR`. The file must contain exactly one `#[lez_program]` module. + +#### What It Generates + +A `fn main()` that: +1. Includes the source file via `include_str!` for cargo dependency tracking +2. Parses the embedded IDL JSON string +3. Pretty-prints it to stdout + +#### Usage + +Create a binary crate (e.g., `examples/src/bin/generate_idl.rs`): + +```rust +/// Generate IDL JSON for the my_program program. +/// +/// Usage: +/// cargo run --bin generate_idl > my_program-idl.json +lez_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); +``` + +Then run: + +```bash +cargo run --bin generate_idl > my_program-idl.json +``` + +The macro detects the `instruction = "..."` attribute on `#[lez_program(...)]` and includes the `instruction_type` field in the generated IDL when present. + +--- + +## Types (lez-framework-core) + +### `LezOutput` + +Return value from instruction handlers. Contains post-states and optional chained calls. + +```rust +pub struct LezOutput { + pub post_states: Vec, + pub chained_calls: Vec, +} +``` + +#### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `states_only` | `fn states_only(post_states: Vec) -> Self` | Create output with only post-states and no chained calls. Most common case. | +| `with_chained_calls` | `fn with_chained_calls(post_states: Vec, chained_calls: Vec) -> Self` | Create output with both post-states and chained calls (cross-program invocation). | +| `empty` | `fn empty() -> Self` | Create an empty output (no states, no calls). | +| `into_parts` | `fn into_parts(self) -> (Vec, Vec)` | Destructure into the tuple form expected by `write_nssa_outputs_with_chained_call`. Used by generated code. | + +#### Example + +```rust +// Most instructions: just return updated states +Ok(LezOutput::states_only(vec![ + AccountPostState::new_claimed(new_account), // init + AccountPostState::new(updated_account), // update +])) + +// Cross-program call +Ok(LezOutput::with_chained_calls( + vec![AccountPostState::new(state.account.clone())], + vec![chained_call], +)) +``` + +--- + +### `LezResult` + +Type alias for instruction handler return types: + +```rust +pub type LezResult = Result; +``` + +All `#[instruction]` functions must return `LezResult`. + +--- + +### `LezError` + +Structured error type for LEZ programs. Borsh-serializable for on-chain representation. + +```rust +#[derive(Error, Debug, BorshSerialize, BorshDeserialize)] +pub enum LezError { /* ... */ } +``` + +#### Variants + +| Variant | Error Code | Fields | Description | +|---------|-----------|--------|-------------| +| `AccountCountMismatch` | 1000 | `expected: usize, actual: usize` | Wrong number of accounts provided | +| `InvalidAccountOwner` | 1001 | `account_index: usize, expected_owner: String` | Account not owned by expected program | +| `AccountAlreadyInitialized` | 1002 | `account_index: usize` | `init` account already has data | +| `AccountNotInitialized` | 1003 | `account_index: usize` | Account expected to be initialized but is empty | +| `InsufficientBalance` | 1004 | `available: u128, requested: u128` | Insufficient balance for operation | +| `DeserializationError` | 1005 | `account_index: usize, message: String` | Failed to deserialize account data | +| `SerializationError` | 1006 | `message: String` | Failed to serialize data | +| `Overflow` | 1007 | `operation: String` | Arithmetic overflow | +| `Unauthorized` | 1008 | `message: String` | Authorization/signer check failed | +| `PdaMismatch` | 1009 | `account_index: usize` | PDA derivation did not match | +| `Custom` | 6000 + code | `code: u32, message: String` | Program-specific error | + +#### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `custom` | `fn custom(code: u32, message: impl Into) -> Self` | Create a custom error. Numeric code starts at 6000. | +| `error_code` | `fn error_code(&self) -> u32` | Get the numeric error code for client-side handling. | + +#### Example + +```rust +// Using built-in variants +if balance < amount { + return Err(LezError::InsufficientBalance { + available: balance, + requested: amount, + }); +} + +// Using custom errors +return Err(LezError::custom(1, "Proposal already executed")); +// error_code() returns 6001 +``` + +--- + +### `AccountConstraint` + +Internal type used by the macro-generated validation code. Not typically used directly. + +```rust +pub struct AccountConstraint { + pub mutable: bool, + pub init: bool, + pub owner: Option<[u8; 32]>, + pub signer: bool, + pub seeds: Option>>, +} +``` + +--- + +### `InstructionMeta` / `AccountMeta` / `ArgMeta` + +Metadata types used for IDL generation. Not typically used directly. + +```rust +pub struct InstructionMeta { + pub name: String, + pub accounts: Vec, + pub args: Vec, +} + +pub struct AccountMeta { + pub name: String, + pub writable: bool, + pub init: bool, + pub owner: Option, + pub signer: bool, + pub pda_seeds: Option>, +} + +pub struct ArgMeta { + pub name: String, + pub type_name: String, +} +``` + +--- + +### Re-exported nssa_core types + +The following types are re-exported through `lez-framework-core::prelude`: + +| Type | Origin | Description | +|------|--------|-------------| +| `Account` | `nssa_core::account` | Generic account wrapper | +| `AccountWithMetadata` | `nssa_core::account` | Account data plus `account_id` and `is_authorized` flag. Primary type used in instruction handlers. | +| `AccountPostState` | `nssa_core::program` | Represents the state of an account after instruction execution. Use `new()` for existing accounts, `new_claimed()` for newly initialized accounts. | +| `ChainedCall` | `nssa_core::program` | Cross-program invocation data. Returned in `LezOutput::with_chained_calls()`. | +| `PdaSeed` | `nssa_core::program` | A 32-byte PDA seed. Constructed via `PdaSeed::new(bytes)`. | +| `ProgramId` | `nssa_core::program` | Type alias for `[u32; 8]`. Identifies a program by its RISC Zero image ID. | + +--- + +## CLI (lez-cli) + +The `lez-cli` crate provides a generic, IDL-driven CLI for any LEZ program. Programs get a complete CLI by writing a three-line wrapper: + +```rust +#[tokio::main] +async fn main() { + lez_cli::run().await; +} +``` + +### Global Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--idl ` | `-i` | Path to the IDL JSON file. Required for most commands. | +| `--program ` | `-p` | Path to the program ELF binary. Used to compute ProgramId and for deployment. Defaults to `program.bin`. | +| `--program-id ` | | 64-character hex string of the program ID. Overrides `--program` for ProgramId resolution (faster, no binary loading). | +| `--dry-run` | | Print parsed/serialized data without submitting the transaction. | +| `--bin- ` | | Additional program binary. Auto-fills `---program-id` from the binary's image ID. Useful for cross-program references. | + +--- + +### `init` + +Scaffold a new LEZ project. + +```bash +lez-cli init +``` + +**Does not require `--idl`.** + +Creates a complete project structure with: +- Workspace `Cargo.toml` +- `{name}_core/` crate for shared types +- `methods/guest/` with a skeleton `#[lez_program]` guest binary +- `examples/` with `generate_idl.rs` and `{name}_cli.rs` +- `Makefile` with `build`, `idl`, `cli`, `deploy`, `inspect`, `setup`, `status`, `clean` targets +- `README.md` with quick start guide +- `.gitignore` + +**Example:** + +```bash +lez-cli init my-token +cd my-token +# Edit methods/guest/src/bin/my_token.rs with your program logic +make idl +make cli ARGS="--help" +``` + +--- + +### `inspect` + +Print the ProgramId for one or more ELF binaries. + +```bash +lez-cli inspect [FILE...] +``` + +**Does not require `--idl`.** + +**Output for each binary:** + +``` +📦 path/to/program.bin + ProgramId (decimal): 12345,67890,11111,22222,33333,44444,55555,66666 + ProgramId (hex): 00003039,000109b2,... + ImageID (hex bytes): 393000009b210100... +``` + +The three formats: +- **Decimal**: comma-separated `[u32; 8]` values +- **Hex**: comma-separated hex `[u32; 8]` values +- **ImageID hex bytes**: 64-character hex string (little-endian byte representation) + +**Example:** + +```bash +lez-cli inspect methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin +``` + +--- + +### `idl` (command) + +Print the loaded IDL as pretty-printed JSON. + +```bash +lez-cli --idl idl +``` + +**Example:** + +```bash +lez-cli -i my_program-idl.json idl +``` + +--- + +### `pda` (IDL mode) + +Compute a PDA address from the IDL-defined seeds. + +```bash +lez-cli --idl [-p | --program-id ] pda [-- ...] +``` + +Looks up the named account across all instructions in the IDL, finds its PDA seed definition, resolves all seeds, and prints the base58 AccountId. + +**Seed resolution:** +- `const` seeds: resolved from the IDL definition +- `arg` seeds: must be provided via `-- ` +- `account` seeds: must be provided via `---account ` + +**ProgramId resolution** (in priority order): +1. `--program-id ` flag +2. Load from `--program ` binary + +**Example:** + +```bash +# Simple PDA with only const seeds +lez-cli -i my_program-idl.json --program-id abc123...def pda counter + +# PDA with arg seed +lez-cli -i multisig-idl.json --program-id abc123...def pda multisig_state \ + --create-key 0a1b2c... + +# List available PDAs +lez-cli -i my_program-idl.json pda +``` + +**If no account name is given**, prints all PDA accounts found in the IDL. + +--- + +### `pda` (raw mode) + +Compute an arbitrary PDA from a program ID and raw seeds — no IDL required. + +```bash +lez-cli --program-id <64-CHAR-HEX> pda [SEED2] ... +``` + +**Does not require `--idl`.** + +Each seed can be: +- **64-character hex string**: interpreted as 32 raw bytes +- **Plain string**: UTF-8 encoded and zero-padded to 32 bytes (max 32 bytes) + +**Multi-seed derivation:** `SHA-256(seed1_32 || seed2_32 || ...)` + +**Output:** base58 AccountId + +**Example:** + +```bash +# Single seed +lez-cli --program-id abc123...def pda my_state_name + +# Multiple seeds +lez-cli --program-id abc123...def pda multisig_vault__ 0a1b2c3d... +``` + +--- + +### Instruction Execution + +Execute any instruction defined in the IDL. The CLI auto-generates subcommands from the IDL. + +```bash +lez-cli --idl [-p | --program-id ] [-- ...] [---account ...] +``` + +Instruction names are converted from `snake_case` to `kebab-case` in CLI commands (e.g., `create_proposal` → `create-proposal`). + +**Arguments:** +- Instruction args: `-- ` (type-aware parsing from IDL) +- Non-PDA accounts: `---account ` (64 hex chars or base58 string) +- PDA accounts: **auto-computed** from seeds — not passed as arguments +- Rest (variadic) accounts: optional, comma-separated list of account IDs + +**Additional program binaries:** Use `--bin- ` to auto-fill `---program-id` from the binary's image ID. + +**Transaction flow:** +1. Parse and validate all arguments +2. Auto-fill program IDs from `--bin-*` flags +3. Serialize instruction data in risc0 serde format +4. Resolve PDA accounts from seeds +5. Initialize wallet from `NSSA_WALLET_HOME_DIR` environment variable +6. Fetch nonces for signer accounts +7. Build, sign, and submit the transaction +8. Poll for confirmation + +**Per-instruction help:** + +```bash +lez-cli --idl --help +``` + +Shows accounts (with flags like `[mut, signer, init]`), PDA status, and argument types. + +**Example:** + +```bash +# Execute a create instruction +lez-cli -i multisig-idl.json -p multisig.bin create \ + --create-key 0a1b2c3d4e5f... \ + --threshold 2 \ + --members "aabb...00,ccdd...00" \ + --creator-account EjR7...base58 + +# Dry run (no submission) +lez-cli -i multisig-idl.json --program-id abc123...def --dry-run approve \ + --proposal-id 5 \ + --proposal-account aabb...00 \ + --member-account ccdd...00 + +# Auto-fill cross-program reference +lez-cli -i treasury-idl.json -p treasury.bin \ + --bin-token token.bin \ + transfer --amount 100 \ + --from-account aabb...00 \ + --to-account ccdd...00 +``` + +--- + +### Type Format Table + +How to pass values for each IDL type on the command line: + +| IDL Type | CLI Format | Example | +|----------|-----------|---------| +| `u8` | Decimal number | `255` | +| `u32` | Decimal number | `1000000` | +| `u64` | Decimal number | `1000000000` | +| `u128` | Decimal number | `340282366920938463463374607431768211455` | +| `bool` | `true`/`false`/`1`/`0`/`yes`/`no` | `true` | +| `string` / `String` | Plain text | `"hello world"` | +| `[u8; N]` | Hex string (`2*N` hex chars) **or** UTF-8 string (≤N chars, zero-padded) | `0a1b2c...` (64 chars for N=32) or `my_string` | +| `[u32; 8]` / `program_id` | 8 comma-separated u32 values, or 64 hex chars | `0,0,0,0,0,0,0,0` or `abc123...def` | +| `Vec<[u8; 32]>` | Comma-separated hex strings | `"aabb...00,ccdd...00"` | +| `Vec` | Comma-separated decimal bytes | `1,2,3,4,5` | +| `Vec` | Comma-separated u32 values | `100,200,300` | +| `Option` | `none`/`null`/empty for None; otherwise same as inner type | `none` or `42` | +| Account IDs | Base58 string **or** 64 hex chars (with optional `0x` prefix) | `EjR7...` or `0xaabb...00` | + +**Notes:** +- `[u8; N]` accepts both hex and string formats. Hex is detected by length (exactly `2*N` chars, all hex digits). Otherwise treated as UTF-8 and zero-padded. +- `0x` prefix is accepted and stripped for hex values. +- `program_id` values can also use `0x`-prefixed hex for individual u32 components. + +--- + +## IDL Format + +The IDL (Interface Definition Language) is a JSON file that describes a LEZ program's complete interface. It is generated by the `generate_idl!` macro or the `__program_idl()` function. + +### Top-Level Schema + +```json +{ + "version": "0.1.0", + "name": "program_name", + "instructions": [ /* ... */ ], + "accounts": [], + "types": [], + "errors": [], + "spec": "0.1.0", + "metadata": { + "name": "program_name", + "version": "0.1.0" + }, + "instruction_type": "my_crate::Instruction" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `version` | `string` | Yes | IDL version. Currently `"0.1.0"`. | +| `name` | `string` | Yes | Program name (from the module name). | +| `instructions` | `array` | Yes | List of instruction definitions. | +| `accounts` | `array` | Yes | Account type definitions (currently unused, always `[]`). | +| `types` | `array` | Yes | Custom type definitions (currently unused, always `[]`). | +| `errors` | `array` | Yes | Error definitions (currently unused, always `[]`). | +| `spec` | `string` | No | IDL spec version identifier (lssa-lang compat). | +| `metadata` | `object` | No | Program metadata with `name` and `version` (lssa-lang compat). | +| `instruction_type` | `string` | No | Fully-qualified Rust path to external instruction enum. When set, generated FFI imports this type. E.g., `"multisig_core::Instruction"`. | + +--- + +### `instructions` + +Each instruction object: + +```json +{ + "name": "create_proposal", + "accounts": [ /* ... */ ], + "args": [ /* ... */ ], + "discriminator": [72, 137, 94, 219, 188, 57, 3, 12], + "execution": { "public": true, "private_owned": false }, + "variant": "CreateProposal" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | Yes | Instruction name in `snake_case`. | +| `accounts` | `array` | Yes | Accounts expected by this instruction (in order). | +| `args` | `array` | Yes | Instruction arguments. | +| `discriminator` | `array` | No | SHA-256("global:{name}")[..8] — 8-byte discriminator (lssa-lang compat). | +| `execution` | `object` | No | `{ "public": bool, "private_owned": bool }` — execution mode (lssa-lang compat). Defaults to public. | +| `variant` | `string` | No | PascalCase variant name in the `Instruction` enum (lssa-lang compat). | + +--- + +### `accounts` (in instructions) + +Each account object within an instruction: + +```json +{ + "name": "multisig_state", + "writable": true, + "signer": false, + "init": true, + "owner": null, + "pda": { + "seeds": [ + { "kind": "const", "value": "multisig_state__" }, + { "kind": "arg", "path": "create_key" } + ] + }, + "rest": false, + "visibility": ["public"] +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | `string` | | Account name from the function parameter. | +| `writable` | `bool` | `false` | Whether the account is modified by this instruction. Set by `mut` or `init`. | +| `signer` | `bool` | `false` | Whether the account must sign the transaction. | +| `init` | `bool` | `false` | Whether this is a new account being initialized. | +| `owner` | `string?` | `null` | Expected owner program ID (hex string), or null. | +| `pda` | `object?` | `null` | PDA derivation specification, or null for non-PDA accounts. | +| `rest` | `bool` | `false` | If true, this account represents a variable-length trailing account list (`Vec`). | +| `visibility` | `array` | `[]` | Visibility tags (lssa-lang compat). Typically `["public"]`. | + +--- + +### `args` + +Each argument object: + +```json +{ + "name": "threshold", + "type": "u64" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Argument name in `snake_case`. | +| `type` | `IdlType` | The argument type. See [IDL Types](#idl-types). | + +--- + +### `pda` + +PDA derivation specification: + +```json +{ + "seeds": [ + { "kind": "const", "value": "multisig_state__" }, + { "kind": "account", "path": "user" }, + { "kind": "arg", "path": "create_key" } + ] +} +``` + +**Seed kinds:** + +| Kind | Fields | Description | +|------|--------|-------------| +| `const` | `value: string` | Constant string seed. UTF-8 bytes, zero-padded to 32 bytes. | +| `account` | `path: string` | References another account by name. Uses the account's 32-byte ID. | +| `arg` | `path: string` | References an instruction argument by name. Value is converted to 32 bytes (type-dependent). | + +**Derivation:** +- Single seed: used directly as a 32-byte PDA seed +- Multiple seeds: `SHA-256(seed1_bytes || seed2_bytes || ...)` → 32-byte combined seed +- Final: `AccountId::from((program_id, &PdaSeed::new(combined_seed)))` + +--- + +### IDL Types + +Types in the IDL are represented as JSON using an untagged format: + +| Rust Type | IDL JSON | Description | +|-----------|----------|-------------| +| `u8` | `"u8"` | Primitive string | +| `u16` | `"u16"` | Primitive string | +| `u32` | `"u32"` | Primitive string | +| `u64` | `"u64"` | Primitive string | +| `u128` | `"u128"` | Primitive string | +| `i8`–`i128` | `"i8"`–`"i128"` | Signed integer primitives | +| `bool` | `"bool"` | Primitive string | +| `String` | `"string"` | Primitive string | +| `ProgramId` | `"program_id"` | Alias for `[u32; 8]` | +| `AccountId` | `"account_id"` | Alias for `[u8; 32]` | +| `Vec` | `{ "vec": }` | Vector of inner type | +| `Option` | `{ "option": }` | Optional inner type | +| `[T; N]` | `{ "array": [, N] }` | Fixed-size array | +| Custom type | `{ "defined": "TypeName" }` | Reference to a named type | + +**Examples:** + +```json +"u64" +{ "vec": "u8" } +{ "vec": { "array": ["u8", 32] } } +{ "option": "string" } +{ "array": ["u8", 32] } +{ "defined": "MyCustomStruct" } +``` + +--- + +### `accounts` (top-level) + +Top-level account type definitions. Each entry describes a named account struct: + +```json +{ + "name": "MultisigState", + "type": { + "kind": "struct", + "fields": [ + { "name": "threshold", "type": "u64" }, + { "name": "members", "type": { "vec": { "array": ["u8", 32] } } } + ] + } +} +``` + +Currently generated as an empty array by the macro. Future versions may populate this from account struct definitions. + +--- + +### `types` (section) + +Custom type definitions (struct or enum): + +```json +{ + "kind": "struct", + "fields": [ + { "name": "field_name", "type": "u64" } + ], + "variants": [] +} +``` + +For enums: + +```json +{ + "kind": "enum", + "fields": [], + "variants": [ + { "name": "Active", "fields": [] }, + { "name": "WithData", "fields": [{ "name": "value", "type": "u64" }] } + ] +} +``` + +Currently generated as an empty array by the macro. + +--- + +### `errors` (section) + +Error definitions: + +```json +{ + "code": 1000, + "name": "AccountCountMismatch", + "msg": "Expected {expected} accounts, got {actual}" +} +``` + +Currently generated as an empty array by the macro. The built-in `LezError` variants have fixed codes (see [LezError](#lezerror)). + +--- + +### Discriminators + +Each instruction can have a `discriminator` field — an 8-byte array computed as: + +``` +SHA-256("global:{instruction_name}")[..8] +``` + +This matches the lssa-lang convention. The discriminator is computed at macro expansion time and included in the IDL generated by `__program_idl()`. + +**Example:** For instruction `create`: +``` +SHA-256("global:create")[0..8] = [72, 137, 94, 219, 188, 57, 3, 12] +``` + +The `compute_discriminator()` function in `lez-framework-core/src/idl.rs` and `lez-framework-macros/src/lib.rs` implements this. + +--- + +### `spec` and `metadata` + +lssa-lang compatibility fields: + +```json +{ + "spec": "0.1.0", + "metadata": { + "name": "program_name", + "version": "0.1.0" + } +} +``` + +These are included in the `__program_idl()` output but omitted from the `PROGRAM_IDL_JSON` const string. + +--- + +### `instruction_type` + +When `#[lez_program(instruction = "my_crate::Instruction")]` is used, the IDL includes: + +```json +{ + "instruction_type": "my_crate::Instruction" +} +``` + +This tells `lez-client-gen` to import and use the external type in generated FFI code (instead of generating a local `#[derive(Serialize, Deserialize)]` enum), ensuring correct serialization for the zkVM guest. + +--- + +### Full Example IDL + +```json +{ + "version": "0.1.0", + "name": "my_multisig", + "instructions": [ + { + "name": "create", + "accounts": [ + { + "name": "multisig_state", + "writable": true, + "signer": false, + "init": true, + "pda": { + "seeds": [ + { "kind": "const", "value": "multisig_state__" }, + { "kind": "arg", "path": "create_key" } + ] + } + }, + { + "name": "creator", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { "name": "create_key", "type": { "array": ["u8", 32] } }, + { "name": "threshold", "type": "u64" }, + { "name": "members", "type": { "vec": { "array": ["u8", 32] } } } + ], + "discriminator": [72, 137, 94, 219, 188, 57, 3, 12], + "execution": { "public": true, "private_owned": false }, + "variant": "Create" + }, + { + "name": "approve", + "accounts": [ + { + "name": "multisig_state", + "writable": false, + "signer": false, + "init": false, + "pda": { + "seeds": [ + { "kind": "const", "value": "multisig_state__" } + ] + } + }, + { + "name": "proposal", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "member", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { "name": "proposal_id", "type": "u64" } + ], + "discriminator": [18, 234, 43, 71, 52, 0, 12, 8], + "execution": { "public": true, "private_owned": false }, + "variant": "Approve" + } + ], + "accounts": [], + "types": [], + "errors": [], + "instruction_type": "multisig_core::Instruction" +} +``` + +--- + +## Client Code Generation (lez-client-gen) + +Generates typed Rust client code and C FFI wrappers from LEZ program IDL JSON. Useful for integrating LEZ programs into applications (e.g., C++/Qt desktop apps). + +### lez-client-gen CLI Usage + +```bash +lez-client-gen --idl --out-dir +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--idl ` | Yes | Path to the IDL JSON file. | +| `--out-dir ` | Yes | Output directory for generated files. Created if it doesn't exist. | +| `--help`, `-h` | | Print help. | + +**Output files** (named after the program): + +``` +/ +├── _client.rs # Typed Rust client +├── _ffi.rs # C FFI wrappers +└── .h # C header file +``` + +**Example:** + +```bash +lez-client-gen --idl multisig-idl.json --out-dir generated/ + +# Output: +# Generated: +# Client: generated/my_multisig_client.rs +# FFI: generated/my_multisig_ffi.rs +# Header: generated/my_multisig.h +``` + +### Library API + +```rust +use lez_client_gen::{generate_from_idl_json, generate_from_idl, CodegenOutput}; + +// From JSON string +let json = std::fs::read_to_string("idl.json")?; +let output: CodegenOutput = generate_from_idl_json(&json)?; + +// From parsed IDL +let idl: LezIdl = serde_json::from_str(&json)?; +let output: CodegenOutput = generate_from_idl(&idl)?; + +// Write output files +std::fs::write("client.rs", &output.client_code)?; +std::fs::write("ffi.rs", &output.ffi_code)?; +std::fs::write("program.h", &output.header)?; +``` + +**`CodegenOutput` fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `client_code` | `String` | Typed Rust client module source code | +| `ffi_code` | `String` | C FFI wrapper source code | +| `header` | `String` | C header file content | + +--- + +### Generated Rust Client + +The generated client includes: + +1. **Instruction enum** — `{ProgramName}Instruction` with all variants +2. **Account structs** — one `{InstructionName}Accounts` struct per instruction, with fields for each account (using `AccountId` for single accounts, `Vec` for rest accounts) +3. **Client struct** — `{ProgramName}Client<'w>` with: + - Constructor: `new(wallet: &WalletCore, program_id: ProgramId)` + - One async method per instruction that builds, signs, and submits the transaction + - PDA helper methods: `compute_{account}_pda(...)` for each PDA account +4. **Helper functions** — `parse_program_id_hex()`, `compute_pda()` + +**Example generated code** (for a multisig program): + +```rust +pub enum MyMultisigInstruction { + Create { create_key: [u8; 32], threshold: u64, members: Vec<[u8; 32]> }, + Approve { proposal_id: u64 }, +} + +pub struct CreateAccounts { + pub multisig_state: AccountId, + pub creator: AccountId, +} + +pub struct MyMultisigClient<'w> { + pub wallet: &'w WalletCore, + pub program_id: ProgramId, +} + +impl<'w> MyMultisigClient<'w> { + pub fn new(wallet: &'w WalletCore, program_id: ProgramId) -> Self { /* ... */ } + pub async fn create(&self, accounts: CreateAccounts, create_key: [u8; 32], ...) -> Result { /* ... */ } + pub async fn approve(&self, accounts: ApproveAccounts, proposal_id: u64) -> Result { /* ... */ } + pub fn compute_multisig_state_pda(create_key: &[u8; 32]) -> AccountId { /* ... */ } +} +``` + +--- + +### Generated C FFI + +The FFI module generates `extern "C"` functions that accept and return JSON strings. Each instruction gets a function: + +```rust +#[no_mangle] +pub extern "C" fn my_multisig_create(args_json: *const c_char) -> *mut c_char { /* ... */ } + +#[no_mangle] +pub extern "C" fn my_multisig_approve(args_json: *const c_char) -> *mut c_char { /* ... */ } + +#[no_mangle] +pub extern "C" fn my_multisig_free_string(s: *mut c_char) { /* ... */ } + +#[no_mangle] +pub extern "C" fn my_multisig_version() -> *mut c_char { /* ... */ } +``` + +**Required JSON fields for every instruction call:** + +| Field | Type | Description | +|-------|------|-------------| +| `wallet_path` | `string` | Path to the NSSA wallet directory | +| `sequencer_url` | `string` | Sequencer URL (e.g., `"http://127.0.0.1:3040"`) | +| `program_id_hex` | `string` | 64-character hex string of the program ID | + +Plus instruction-specific fields for accounts and arguments. + +**Return format:** + +```json +// Success +{ "success": true, "tx_hash": "abc123..." } + +// Error +{ "success": false, "error": "error message" } +``` + +**Instruction type handling:** +- If the IDL has `instruction_type` set, the FFI imports and uses that type directly (`use path::to::Instruction as ProgramInstruction;`) +- Otherwise, a local `#[derive(Serialize, Deserialize)]` enum is generated + +**PDA helpers:** Standalone `compute_{account}_pda()` functions are generated for each unique PDA account. Single-seed PDAs use `PdaSeed` directly; multi-seed PDAs use SHA-256 combination. + +--- + +### Generated C Header + +```c +/* Auto-generated C header for my_multisig FFI. DO NOT EDIT. */ +#ifndef MY_MULTISIG_FFI_H +#define MY_MULTISIG_FFI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* create instruction */ +char* my_multisig_create(const char* args_json); + +/* approve instruction */ +char* my_multisig_approve(const char* args_json); + +void my_multisig_free_string(char* s); +char* my_multisig_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* MY_MULTISIG_FFI_H */ +``` + +--- + +### Using from C++/Qt + +1. **Generate the FFI:** + +```bash +lez-client-gen --idl my_program-idl.json --out-dir ffi/ +``` + +2. **Build as a shared library** by adding to your `Cargo.toml`: + +```toml +[lib] +name = "my_program_ffi" +crate-type = ["cdylib"] +``` + +Include the generated FFI code in your lib: + +```rust +// src/lib.rs +include!("../ffi/my_program_ffi.rs"); +``` + +3. **Build:** + +```bash +cargo build --release --lib +# Produces: target/release/libmy_program_ffi.so (Linux) +# target/release/libmy_program_ffi.dylib (macOS) +``` + +4. **Use from C++/Qt:** + +```cpp +#include "my_program.h" +#include +#include + +// Build the JSON arguments +QJsonObject args; +args["wallet_path"] = "/path/to/wallet"; +args["program_id_hex"] = "abc123..."; +args["amount"] = 5; +args["owner"] = "base58-account-id"; + +QByteArray json = QJsonDocument(args).toJson(QJsonDocument::Compact); +char* result = my_program_increment(json.constData()); + +// Parse the result +QJsonDocument resultDoc = QJsonDocument::fromJson(result); +bool success = resultDoc.object()["success"].toBool(); +QString txHash = resultDoc.object()["tx_hash"].toString(); + +// Free the result string +my_program_free_string(result); +``` + +--- + +## Validation + +### Generated Validation Functions + +The `#[lez_program]` macro generates validation functions for instructions that have `signer` or `init` constraints. These run automatically before the handler. + +**Generated function signature:** + +```rust +pub fn __validate_{instruction_name}( + accounts: &[AccountWithMetadata] +) -> Result<(), LezError> +``` + +**Checks performed (in order):** + +1. **Signer checks** — for each `#[account(signer)]`: + ```rust + if !accounts[idx].is_authorized { + return Err(LezError::Unauthorized { + message: "Account '{name}' (index {idx}) must be a signer", + }); + } + ``` + +2. **Init checks** — for each `#[account(init)]`: + ```rust + if accounts[idx].account != Account::default() { + return Err(LezError::AccountAlreadyInitialized { + account_index: idx, + }); + } + ``` + +If an instruction has no `signer` or `init` constraints, no validation function is generated. + +--- + +### Validation Helpers + +The `lez-framework-core::validation` module provides helper functions used by generated code: + +| Function | Signature | Description | +|----------|-----------|-------------| +| `validate_account_count` | `fn(actual: usize, expected: usize) -> Result<(), LezError>` | Check that the correct number of accounts was provided. Returns `AccountCountMismatch` on failure. | +| `validate_accounts` | `fn(account_count: usize, constraints: &[AccountConstraint]) -> Result<(), LezError>` | Validate accounts against constraints. Currently checks count; ownership, init state, signer, and PDA checks are delegated to the macro-generated code. | +| `is_default_account` | `fn(data: &[u8]) -> bool` | Check if account data is empty or all zeros. Used for `init` constraint. | +| `verify_owner` | `fn(account_owner: &[u8; 32], expected_owner: &[u8; 32], account_index: usize) -> Result<(), LezError>` | Verify account ownership. Returns `InvalidAccountOwner` on mismatch. | + +--- + +## Serialization (lez-cli internals) + +The CLI serializes instruction data in **risc0 serde format** (`Vec`) for submission to the zkVM guest. The format is: + +``` +[variant_index: u32, field1_words..., field2_words..., ...] +``` + +**Per-type encoding:** + +| Type | Encoding | +|------|----------| +| `bool` | 1 word: `0` or `1` | +| `u8` | 1 word (zero-extended) | +| `u32` | 1 word | +| `u64` | 2 words (little-endian) | +| `u128` | 4 words (little-endian) | +| `program_id` / `[u32; 8]` | 8 words | +| `[u8; N]` | N words (each byte zero-extended to u32) | +| `String` | `[length: u32, bytes...]` (bytes padded to u32 words) | +| `Vec` | `[length: u32, elements...]` | +| `Option` | `[0]` for None; `[1, value...]` for Some | + +This matches `risc0_zkvm::serde::to_vec` for enum struct variants. + +--- + +## Prelude + +Import the prelude for convenient access to common types: + +```rust +use lez_framework::prelude::*; +``` + +This imports: +- `lez_program` (macro) +- `instruction` (macro) +- `LezOutput` +- `LezError`, `LezResult` +- `AccountConstraint` +- `Account`, `AccountWithMetadata` +- `AccountPostState`, `ChainedCall`, `PdaSeed`, `ProgramId` +- `BorshSerialize`, `BorshDeserialize` From c09192f9166e14f1b70e6e955657f2dd43afdc74 Mon Sep 17 00:00:00 2001 From: Jimmy Claw Date: Tue, 10 Mar 2026 05:52:46 +0000 Subject: [PATCH 3/7] docs: split reference.md into focused files with index Co-Authored-By: Claude Opus 4.6 --- docs/reference.md | 1411 ---------------------------------- docs/reference/README.md | 15 + docs/reference/cli.md | 290 +++++++ docs/reference/client-gen.md | 241 ++++++ docs/reference/idl.md | 381 +++++++++ docs/reference/macros.md | 276 +++++++ docs/reference/types.md | 184 +++++ docs/tutorial.md | 2 +- 8 files changed, 1388 insertions(+), 1412 deletions(-) delete mode 100644 docs/reference.md create mode 100644 docs/reference/README.md create mode 100644 docs/reference/cli.md create mode 100644 docs/reference/client-gen.md create mode 100644 docs/reference/idl.md create mode 100644 docs/reference/macros.md create mode 100644 docs/reference/types.md diff --git a/docs/reference.md b/docs/reference.md deleted file mode 100644 index 8865e61b..00000000 --- a/docs/reference.md +++ /dev/null @@ -1,1411 +0,0 @@ -# SPEL Framework Reference - -Comprehensive API reference for the SPEL framework (`logos-co/spel`). This document covers every macro, type, CLI command, IDL schema, and code generation feature. - -For a guided walkthrough, see the [Tutorial](tutorial.md). - -## Table of Contents - -- [Macros](#macros) - - [`#[lez_program]`](#lez_program) - - [`#[instruction]`](#instruction) - - [`generate_idl!`](#generate_idl) -- [Types (lez-framework-core)](#types-lez-framework-core) - - [`LezOutput`](#lezoutput) - - [`LezResult`](#lezresult) - - [`LezError`](#lezerror) - - [`AccountConstraint`](#accountconstraint) - - [`InstructionMeta` / `AccountMeta` / `ArgMeta`](#instructionmeta--accountmeta--argmeta) - - [Re-exported nssa_core types](#re-exported-nssa_core-types) -- [CLI (lez-cli)](#cli-lez-cli) - - [Global Options](#global-options) - - [`init`](#init) - - [`inspect`](#inspect) - - [`idl`](#idl-command) - - [`pda` (IDL mode)](#pda-idl-mode) - - [`pda` (raw mode)](#pda-raw-mode) - - [Instruction Execution](#instruction-execution) - - [Type Format Table](#type-format-table) -- [IDL Format](#idl-format) - - [Top-Level Schema](#top-level-schema) - - [`instructions`](#instructions) - - [`accounts` (in instructions)](#accounts-in-instructions) - - [`args`](#args) - - [`pda`](#pda) - - [IDL Types](#idl-types) - - [`accounts` (top-level)](#accounts-top-level) - - [`types`](#types-section) - - [`errors`](#errors-section) - - [Discriminators](#discriminators) - - [`spec` and `metadata`](#spec-and-metadata) - - [`instruction_type`](#instruction_type) - - [Full Example IDL](#full-example-idl) -- [Client Code Generation (lez-client-gen)](#client-code-generation-lez-client-gen) - - [CLI Usage](#lez-client-gen-cli-usage) - - [Library API](#library-api) - - [Generated Rust Client](#generated-rust-client) - - [Generated C FFI](#generated-c-ffi) - - [Generated C Header](#generated-c-header) - - [Using from C++/Qt](#using-from-cqt) -- [Validation](#validation) - - [Generated Validation Functions](#generated-validation-functions) - - [Validation Helpers](#validation-helpers) - ---- - -## Macros - -### `#[lez_program]` - -**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) - -Attribute macro applied to a module. Transforms a module of `#[instruction]` functions into a complete LEZ guest binary with dispatch, validation, and IDL generation. - -#### Syntax - -```rust -#[lez_program] -mod my_program { - #[instruction] - pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } -} -``` - -With external instruction enum: - -```rust -#[lez_program(instruction = "my_crate::Instruction")] -mod my_program { - #[instruction] - pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } -} -``` - -#### Attributes - -| Attribute | Type | Description | -|-----------|------|-------------| -| `instruction` | `"path::to::Enum"` | Optional. External instruction enum path. When set, the macro imports this type instead of generating its own `Instruction` enum. Used when the enum must be shared between on-chain and off-chain code (e.g., for correct borsh serialization in FFI). | - -#### What It Generates - -1. **`Instruction` enum** — a `#[derive(Debug, Clone, Serialize, Deserialize)]` enum with one variant per `#[instruction]` function. Variant names are PascalCase conversions of function names. Only non-account parameters become enum fields. Skipped if `instruction = "..."` attribute is set. - -2. **`fn main()`** — the zkVM guest entry point (gated by `#[cfg(not(test))]`). Reads `ProgramInput` from the host, dispatches to the correct handler via `match`, and writes outputs via `write_nssa_outputs_with_chained_call`. Account destructuring from `pre_states` is generated per-instruction. - -3. **Validation functions** — one `__validate_{fn_name}()` function per instruction that has `signer` or `init` constraints. These run before the handler and return `LezError` on failure. - -4. **`PROGRAM_IDL_JSON`** — a `pub const &str` containing the complete IDL as JSON. Available at compile time in any build target. - -5. **`__program_idl()`** — a function returning a constructed `LezIdl` struct (with discriminators, execution metadata, etc.). - -#### Constraints - -- The module **must have a body** (not `mod foo;`). -- The module must contain **at least one** `#[instruction]` function. -- Instruction functions **cannot have `self`** parameters. -- Account parameters must be typed `AccountWithMetadata` or `Vec`. - -#### Example - -```rust -use lez_framework::prelude::*; -use nssa_core::account::AccountWithMetadata; -use nssa_core::program::AccountPostState; - -#[lez_program] -mod treasury { - use super::*; - - #[instruction] - pub fn deposit( - #[account(mut, pda = literal("vault"))] - vault: AccountWithMetadata, - #[account(signer)] - depositor: AccountWithMetadata, - amount: u128, - ) -> LezResult { - // ... business logic ... - Ok(LezOutput::states_only(vec![ - AccountPostState::new(vault.account.clone()), - AccountPostState::new(depositor.account.clone()), - ])) - } -} -``` - -This generates: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Instruction { - Deposit { amount: u128 }, -} -``` - ---- - -### `#[instruction]` - -**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) - -Marker attribute for functions inside an `#[lez_program]` module. This attribute is processed by `#[lez_program]` — it is a no-op when used standalone. - -#### Function Signature Requirements - -```rust -#[instruction] -pub fn instruction_name( - // Account parameters — must come first - #[account(/* constraints */)] - account_name: AccountWithMetadata, - - // Variable-length accounts (at most one, must be last account param) - #[account(/* constraints */)] - rest_accounts: Vec, - - // Instruction arguments — after all account params - arg1: u64, - arg2: String, -) -> LezResult { - // ... -} -``` - -- **Return type** must be `LezResult` (alias for `Result`). -- **Account parameters** are identified by type: `AccountWithMetadata` for single accounts, `Vec` for variable-length. -- **All other parameters** are instruction arguments and become fields in the generated `Instruction` enum variant. -- Function names use `snake_case`; generated enum variants use `PascalCase`. - -#### Account Constraint Attributes - -Applied via `#[account(...)]` on account parameters: - -| Constraint | Syntax | Description | -|------------|--------|-------------| -| `signer` | `#[account(signer)]` | Requires `is_authorized == true`. Generates a runtime check before the handler runs. | -| `init` | `#[account(init)]` | Account must be uninitialized (`== Account::default()`). **Implies `mut`**. Used with `AccountPostState::new_claimed()`. | -| `mut` | `#[account(mut)]` | Account is writable. Sets `writable: true` in the IDL. | -| `owner` | `#[account(owner = EXPR)]` | Account must be owned by the given program ID. The expression should resolve to `[u8; 32]`. | -| `pda` | `#[account(pda = SEED)]` | Account address is a PDA derived from the program ID and seed(s). See [PDA Seeds](#pda-seeds) below. Sets the `pda` field in the IDL. | -| `rest` | _(implicit)_ | Not an explicit attribute. When the type is `Vec`, the account is treated as variable-length (`rest: true` in the IDL). | - -Constraints can be combined: - -```rust -#[account(init, signer, pda = literal("state"))] -state: AccountWithMetadata, - -#[account(mut, owner = TOKEN_PROGRAM_ID)] -token_account: AccountWithMetadata, -``` - -#### PDA Seeds - -The `pda` attribute specifies how the account address is derived. Seed types: - -| Seed Type | Syntax | Description | -|-----------|--------|-------------| -| `const` | `literal("string")` or `seed_const("string")` | Constant string, UTF-8 encoded and zero-padded to 32 bytes. Aliases: `const(...)`, `r#const(...)`, `seed_const(...)`, `literal(...)`. | -| `account` | `account("other_account_name")` | Uses another account's 32-byte ID as the seed. | -| `arg` | `arg("argument_name")` | Uses an instruction argument's value as the seed. | - -**Single seed:** - -```rust -#[account(pda = literal("my_state"))] -``` - -**Multiple seeds** (array syntax): - -```rust -#[account(pda = [literal("multisig_state__"), arg("create_key")])] -#[account(pda = [literal("vault"), account("user")])] -#[account(pda = [literal("holding"), account("token"), account("user")])] -``` - -**PDA derivation logic:** -- Each seed is resolved to 32 bytes (strings are zero-padded) -- Single seed: used directly as `PdaSeed` -- Multiple seeds: combined via `SHA-256(seed1_32 || seed2_32 || ...)` into a single 32-byte seed -- Final address: `AccountId::from((program_id, &PdaSeed::new(combined)))` - ---- - -### `generate_idl!` - -**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) - -Proc macro that reads a Rust source file at compile time, finds the `#[lez_program]` module, and generates a `fn main()` that prints the complete IDL as JSON. - -#### Syntax - -```rust -lez_framework::generate_idl!("path/to/program.rs"); -``` - -The path is resolved relative to `CARGO_MANIFEST_DIR`. The file must contain exactly one `#[lez_program]` module. - -#### What It Generates - -A `fn main()` that: -1. Includes the source file via `include_str!` for cargo dependency tracking -2. Parses the embedded IDL JSON string -3. Pretty-prints it to stdout - -#### Usage - -Create a binary crate (e.g., `examples/src/bin/generate_idl.rs`): - -```rust -/// Generate IDL JSON for the my_program program. -/// -/// Usage: -/// cargo run --bin generate_idl > my_program-idl.json -lez_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); -``` - -Then run: - -```bash -cargo run --bin generate_idl > my_program-idl.json -``` - -The macro detects the `instruction = "..."` attribute on `#[lez_program(...)]` and includes the `instruction_type` field in the generated IDL when present. - ---- - -## Types (lez-framework-core) - -### `LezOutput` - -Return value from instruction handlers. Contains post-states and optional chained calls. - -```rust -pub struct LezOutput { - pub post_states: Vec, - pub chained_calls: Vec, -} -``` - -#### Methods - -| Method | Signature | Description | -|--------|-----------|-------------| -| `states_only` | `fn states_only(post_states: Vec) -> Self` | Create output with only post-states and no chained calls. Most common case. | -| `with_chained_calls` | `fn with_chained_calls(post_states: Vec, chained_calls: Vec) -> Self` | Create output with both post-states and chained calls (cross-program invocation). | -| `empty` | `fn empty() -> Self` | Create an empty output (no states, no calls). | -| `into_parts` | `fn into_parts(self) -> (Vec, Vec)` | Destructure into the tuple form expected by `write_nssa_outputs_with_chained_call`. Used by generated code. | - -#### Example - -```rust -// Most instructions: just return updated states -Ok(LezOutput::states_only(vec![ - AccountPostState::new_claimed(new_account), // init - AccountPostState::new(updated_account), // update -])) - -// Cross-program call -Ok(LezOutput::with_chained_calls( - vec![AccountPostState::new(state.account.clone())], - vec![chained_call], -)) -``` - ---- - -### `LezResult` - -Type alias for instruction handler return types: - -```rust -pub type LezResult = Result; -``` - -All `#[instruction]` functions must return `LezResult`. - ---- - -### `LezError` - -Structured error type for LEZ programs. Borsh-serializable for on-chain representation. - -```rust -#[derive(Error, Debug, BorshSerialize, BorshDeserialize)] -pub enum LezError { /* ... */ } -``` - -#### Variants - -| Variant | Error Code | Fields | Description | -|---------|-----------|--------|-------------| -| `AccountCountMismatch` | 1000 | `expected: usize, actual: usize` | Wrong number of accounts provided | -| `InvalidAccountOwner` | 1001 | `account_index: usize, expected_owner: String` | Account not owned by expected program | -| `AccountAlreadyInitialized` | 1002 | `account_index: usize` | `init` account already has data | -| `AccountNotInitialized` | 1003 | `account_index: usize` | Account expected to be initialized but is empty | -| `InsufficientBalance` | 1004 | `available: u128, requested: u128` | Insufficient balance for operation | -| `DeserializationError` | 1005 | `account_index: usize, message: String` | Failed to deserialize account data | -| `SerializationError` | 1006 | `message: String` | Failed to serialize data | -| `Overflow` | 1007 | `operation: String` | Arithmetic overflow | -| `Unauthorized` | 1008 | `message: String` | Authorization/signer check failed | -| `PdaMismatch` | 1009 | `account_index: usize` | PDA derivation did not match | -| `Custom` | 6000 + code | `code: u32, message: String` | Program-specific error | - -#### Methods - -| Method | Signature | Description | -|--------|-----------|-------------| -| `custom` | `fn custom(code: u32, message: impl Into) -> Self` | Create a custom error. Numeric code starts at 6000. | -| `error_code` | `fn error_code(&self) -> u32` | Get the numeric error code for client-side handling. | - -#### Example - -```rust -// Using built-in variants -if balance < amount { - return Err(LezError::InsufficientBalance { - available: balance, - requested: amount, - }); -} - -// Using custom errors -return Err(LezError::custom(1, "Proposal already executed")); -// error_code() returns 6001 -``` - ---- - -### `AccountConstraint` - -Internal type used by the macro-generated validation code. Not typically used directly. - -```rust -pub struct AccountConstraint { - pub mutable: bool, - pub init: bool, - pub owner: Option<[u8; 32]>, - pub signer: bool, - pub seeds: Option>>, -} -``` - ---- - -### `InstructionMeta` / `AccountMeta` / `ArgMeta` - -Metadata types used for IDL generation. Not typically used directly. - -```rust -pub struct InstructionMeta { - pub name: String, - pub accounts: Vec, - pub args: Vec, -} - -pub struct AccountMeta { - pub name: String, - pub writable: bool, - pub init: bool, - pub owner: Option, - pub signer: bool, - pub pda_seeds: Option>, -} - -pub struct ArgMeta { - pub name: String, - pub type_name: String, -} -``` - ---- - -### Re-exported nssa_core types - -The following types are re-exported through `lez-framework-core::prelude`: - -| Type | Origin | Description | -|------|--------|-------------| -| `Account` | `nssa_core::account` | Generic account wrapper | -| `AccountWithMetadata` | `nssa_core::account` | Account data plus `account_id` and `is_authorized` flag. Primary type used in instruction handlers. | -| `AccountPostState` | `nssa_core::program` | Represents the state of an account after instruction execution. Use `new()` for existing accounts, `new_claimed()` for newly initialized accounts. | -| `ChainedCall` | `nssa_core::program` | Cross-program invocation data. Returned in `LezOutput::with_chained_calls()`. | -| `PdaSeed` | `nssa_core::program` | A 32-byte PDA seed. Constructed via `PdaSeed::new(bytes)`. | -| `ProgramId` | `nssa_core::program` | Type alias for `[u32; 8]`. Identifies a program by its RISC Zero image ID. | - ---- - -## CLI (lez-cli) - -The `lez-cli` crate provides a generic, IDL-driven CLI for any LEZ program. Programs get a complete CLI by writing a three-line wrapper: - -```rust -#[tokio::main] -async fn main() { - lez_cli::run().await; -} -``` - -### Global Options - -| Option | Short | Description | -|--------|-------|-------------| -| `--idl ` | `-i` | Path to the IDL JSON file. Required for most commands. | -| `--program ` | `-p` | Path to the program ELF binary. Used to compute ProgramId and for deployment. Defaults to `program.bin`. | -| `--program-id ` | | 64-character hex string of the program ID. Overrides `--program` for ProgramId resolution (faster, no binary loading). | -| `--dry-run` | | Print parsed/serialized data without submitting the transaction. | -| `--bin- ` | | Additional program binary. Auto-fills `---program-id` from the binary's image ID. Useful for cross-program references. | - ---- - -### `init` - -Scaffold a new LEZ project. - -```bash -lez-cli init -``` - -**Does not require `--idl`.** - -Creates a complete project structure with: -- Workspace `Cargo.toml` -- `{name}_core/` crate for shared types -- `methods/guest/` with a skeleton `#[lez_program]` guest binary -- `examples/` with `generate_idl.rs` and `{name}_cli.rs` -- `Makefile` with `build`, `idl`, `cli`, `deploy`, `inspect`, `setup`, `status`, `clean` targets -- `README.md` with quick start guide -- `.gitignore` - -**Example:** - -```bash -lez-cli init my-token -cd my-token -# Edit methods/guest/src/bin/my_token.rs with your program logic -make idl -make cli ARGS="--help" -``` - ---- - -### `inspect` - -Print the ProgramId for one or more ELF binaries. - -```bash -lez-cli inspect [FILE...] -``` - -**Does not require `--idl`.** - -**Output for each binary:** - -``` -📦 path/to/program.bin - ProgramId (decimal): 12345,67890,11111,22222,33333,44444,55555,66666 - ProgramId (hex): 00003039,000109b2,... - ImageID (hex bytes): 393000009b210100... -``` - -The three formats: -- **Decimal**: comma-separated `[u32; 8]` values -- **Hex**: comma-separated hex `[u32; 8]` values -- **ImageID hex bytes**: 64-character hex string (little-endian byte representation) - -**Example:** - -```bash -lez-cli inspect methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin -``` - ---- - -### `idl` (command) - -Print the loaded IDL as pretty-printed JSON. - -```bash -lez-cli --idl idl -``` - -**Example:** - -```bash -lez-cli -i my_program-idl.json idl -``` - ---- - -### `pda` (IDL mode) - -Compute a PDA address from the IDL-defined seeds. - -```bash -lez-cli --idl [-p | --program-id ] pda [-- ...] -``` - -Looks up the named account across all instructions in the IDL, finds its PDA seed definition, resolves all seeds, and prints the base58 AccountId. - -**Seed resolution:** -- `const` seeds: resolved from the IDL definition -- `arg` seeds: must be provided via `-- ` -- `account` seeds: must be provided via `---account ` - -**ProgramId resolution** (in priority order): -1. `--program-id ` flag -2. Load from `--program ` binary - -**Example:** - -```bash -# Simple PDA with only const seeds -lez-cli -i my_program-idl.json --program-id abc123...def pda counter - -# PDA with arg seed -lez-cli -i multisig-idl.json --program-id abc123...def pda multisig_state \ - --create-key 0a1b2c... - -# List available PDAs -lez-cli -i my_program-idl.json pda -``` - -**If no account name is given**, prints all PDA accounts found in the IDL. - ---- - -### `pda` (raw mode) - -Compute an arbitrary PDA from a program ID and raw seeds — no IDL required. - -```bash -lez-cli --program-id <64-CHAR-HEX> pda [SEED2] ... -``` - -**Does not require `--idl`.** - -Each seed can be: -- **64-character hex string**: interpreted as 32 raw bytes -- **Plain string**: UTF-8 encoded and zero-padded to 32 bytes (max 32 bytes) - -**Multi-seed derivation:** `SHA-256(seed1_32 || seed2_32 || ...)` - -**Output:** base58 AccountId - -**Example:** - -```bash -# Single seed -lez-cli --program-id abc123...def pda my_state_name - -# Multiple seeds -lez-cli --program-id abc123...def pda multisig_vault__ 0a1b2c3d... -``` - ---- - -### Instruction Execution - -Execute any instruction defined in the IDL. The CLI auto-generates subcommands from the IDL. - -```bash -lez-cli --idl [-p | --program-id ] [-- ...] [---account ...] -``` - -Instruction names are converted from `snake_case` to `kebab-case` in CLI commands (e.g., `create_proposal` → `create-proposal`). - -**Arguments:** -- Instruction args: `-- ` (type-aware parsing from IDL) -- Non-PDA accounts: `---account ` (64 hex chars or base58 string) -- PDA accounts: **auto-computed** from seeds — not passed as arguments -- Rest (variadic) accounts: optional, comma-separated list of account IDs - -**Additional program binaries:** Use `--bin- ` to auto-fill `---program-id` from the binary's image ID. - -**Transaction flow:** -1. Parse and validate all arguments -2. Auto-fill program IDs from `--bin-*` flags -3. Serialize instruction data in risc0 serde format -4. Resolve PDA accounts from seeds -5. Initialize wallet from `NSSA_WALLET_HOME_DIR` environment variable -6. Fetch nonces for signer accounts -7. Build, sign, and submit the transaction -8. Poll for confirmation - -**Per-instruction help:** - -```bash -lez-cli --idl --help -``` - -Shows accounts (with flags like `[mut, signer, init]`), PDA status, and argument types. - -**Example:** - -```bash -# Execute a create instruction -lez-cli -i multisig-idl.json -p multisig.bin create \ - --create-key 0a1b2c3d4e5f... \ - --threshold 2 \ - --members "aabb...00,ccdd...00" \ - --creator-account EjR7...base58 - -# Dry run (no submission) -lez-cli -i multisig-idl.json --program-id abc123...def --dry-run approve \ - --proposal-id 5 \ - --proposal-account aabb...00 \ - --member-account ccdd...00 - -# Auto-fill cross-program reference -lez-cli -i treasury-idl.json -p treasury.bin \ - --bin-token token.bin \ - transfer --amount 100 \ - --from-account aabb...00 \ - --to-account ccdd...00 -``` - ---- - -### Type Format Table - -How to pass values for each IDL type on the command line: - -| IDL Type | CLI Format | Example | -|----------|-----------|---------| -| `u8` | Decimal number | `255` | -| `u32` | Decimal number | `1000000` | -| `u64` | Decimal number | `1000000000` | -| `u128` | Decimal number | `340282366920938463463374607431768211455` | -| `bool` | `true`/`false`/`1`/`0`/`yes`/`no` | `true` | -| `string` / `String` | Plain text | `"hello world"` | -| `[u8; N]` | Hex string (`2*N` hex chars) **or** UTF-8 string (≤N chars, zero-padded) | `0a1b2c...` (64 chars for N=32) or `my_string` | -| `[u32; 8]` / `program_id` | 8 comma-separated u32 values, or 64 hex chars | `0,0,0,0,0,0,0,0` or `abc123...def` | -| `Vec<[u8; 32]>` | Comma-separated hex strings | `"aabb...00,ccdd...00"` | -| `Vec` | Comma-separated decimal bytes | `1,2,3,4,5` | -| `Vec` | Comma-separated u32 values | `100,200,300` | -| `Option` | `none`/`null`/empty for None; otherwise same as inner type | `none` or `42` | -| Account IDs | Base58 string **or** 64 hex chars (with optional `0x` prefix) | `EjR7...` or `0xaabb...00` | - -**Notes:** -- `[u8; N]` accepts both hex and string formats. Hex is detected by length (exactly `2*N` chars, all hex digits). Otherwise treated as UTF-8 and zero-padded. -- `0x` prefix is accepted and stripped for hex values. -- `program_id` values can also use `0x`-prefixed hex for individual u32 components. - ---- - -## IDL Format - -The IDL (Interface Definition Language) is a JSON file that describes a LEZ program's complete interface. It is generated by the `generate_idl!` macro or the `__program_idl()` function. - -### Top-Level Schema - -```json -{ - "version": "0.1.0", - "name": "program_name", - "instructions": [ /* ... */ ], - "accounts": [], - "types": [], - "errors": [], - "spec": "0.1.0", - "metadata": { - "name": "program_name", - "version": "0.1.0" - }, - "instruction_type": "my_crate::Instruction" -} -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `version` | `string` | Yes | IDL version. Currently `"0.1.0"`. | -| `name` | `string` | Yes | Program name (from the module name). | -| `instructions` | `array` | Yes | List of instruction definitions. | -| `accounts` | `array` | Yes | Account type definitions (currently unused, always `[]`). | -| `types` | `array` | Yes | Custom type definitions (currently unused, always `[]`). | -| `errors` | `array` | Yes | Error definitions (currently unused, always `[]`). | -| `spec` | `string` | No | IDL spec version identifier (lssa-lang compat). | -| `metadata` | `object` | No | Program metadata with `name` and `version` (lssa-lang compat). | -| `instruction_type` | `string` | No | Fully-qualified Rust path to external instruction enum. When set, generated FFI imports this type. E.g., `"multisig_core::Instruction"`. | - ---- - -### `instructions` - -Each instruction object: - -```json -{ - "name": "create_proposal", - "accounts": [ /* ... */ ], - "args": [ /* ... */ ], - "discriminator": [72, 137, 94, 219, 188, 57, 3, 12], - "execution": { "public": true, "private_owned": false }, - "variant": "CreateProposal" -} -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | `string` | Yes | Instruction name in `snake_case`. | -| `accounts` | `array` | Yes | Accounts expected by this instruction (in order). | -| `args` | `array` | Yes | Instruction arguments. | -| `discriminator` | `array` | No | SHA-256("global:{name}")[..8] — 8-byte discriminator (lssa-lang compat). | -| `execution` | `object` | No | `{ "public": bool, "private_owned": bool }` — execution mode (lssa-lang compat). Defaults to public. | -| `variant` | `string` | No | PascalCase variant name in the `Instruction` enum (lssa-lang compat). | - ---- - -### `accounts` (in instructions) - -Each account object within an instruction: - -```json -{ - "name": "multisig_state", - "writable": true, - "signer": false, - "init": true, - "owner": null, - "pda": { - "seeds": [ - { "kind": "const", "value": "multisig_state__" }, - { "kind": "arg", "path": "create_key" } - ] - }, - "rest": false, - "visibility": ["public"] -} -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `name` | `string` | | Account name from the function parameter. | -| `writable` | `bool` | `false` | Whether the account is modified by this instruction. Set by `mut` or `init`. | -| `signer` | `bool` | `false` | Whether the account must sign the transaction. | -| `init` | `bool` | `false` | Whether this is a new account being initialized. | -| `owner` | `string?` | `null` | Expected owner program ID (hex string), or null. | -| `pda` | `object?` | `null` | PDA derivation specification, or null for non-PDA accounts. | -| `rest` | `bool` | `false` | If true, this account represents a variable-length trailing account list (`Vec`). | -| `visibility` | `array` | `[]` | Visibility tags (lssa-lang compat). Typically `["public"]`. | - ---- - -### `args` - -Each argument object: - -```json -{ - "name": "threshold", - "type": "u64" -} -``` - -| Field | Type | Description | -|-------|------|-------------| -| `name` | `string` | Argument name in `snake_case`. | -| `type` | `IdlType` | The argument type. See [IDL Types](#idl-types). | - ---- - -### `pda` - -PDA derivation specification: - -```json -{ - "seeds": [ - { "kind": "const", "value": "multisig_state__" }, - { "kind": "account", "path": "user" }, - { "kind": "arg", "path": "create_key" } - ] -} -``` - -**Seed kinds:** - -| Kind | Fields | Description | -|------|--------|-------------| -| `const` | `value: string` | Constant string seed. UTF-8 bytes, zero-padded to 32 bytes. | -| `account` | `path: string` | References another account by name. Uses the account's 32-byte ID. | -| `arg` | `path: string` | References an instruction argument by name. Value is converted to 32 bytes (type-dependent). | - -**Derivation:** -- Single seed: used directly as a 32-byte PDA seed -- Multiple seeds: `SHA-256(seed1_bytes || seed2_bytes || ...)` → 32-byte combined seed -- Final: `AccountId::from((program_id, &PdaSeed::new(combined_seed)))` - ---- - -### IDL Types - -Types in the IDL are represented as JSON using an untagged format: - -| Rust Type | IDL JSON | Description | -|-----------|----------|-------------| -| `u8` | `"u8"` | Primitive string | -| `u16` | `"u16"` | Primitive string | -| `u32` | `"u32"` | Primitive string | -| `u64` | `"u64"` | Primitive string | -| `u128` | `"u128"` | Primitive string | -| `i8`–`i128` | `"i8"`–`"i128"` | Signed integer primitives | -| `bool` | `"bool"` | Primitive string | -| `String` | `"string"` | Primitive string | -| `ProgramId` | `"program_id"` | Alias for `[u32; 8]` | -| `AccountId` | `"account_id"` | Alias for `[u8; 32]` | -| `Vec` | `{ "vec": }` | Vector of inner type | -| `Option` | `{ "option": }` | Optional inner type | -| `[T; N]` | `{ "array": [, N] }` | Fixed-size array | -| Custom type | `{ "defined": "TypeName" }` | Reference to a named type | - -**Examples:** - -```json -"u64" -{ "vec": "u8" } -{ "vec": { "array": ["u8", 32] } } -{ "option": "string" } -{ "array": ["u8", 32] } -{ "defined": "MyCustomStruct" } -``` - ---- - -### `accounts` (top-level) - -Top-level account type definitions. Each entry describes a named account struct: - -```json -{ - "name": "MultisigState", - "type": { - "kind": "struct", - "fields": [ - { "name": "threshold", "type": "u64" }, - { "name": "members", "type": { "vec": { "array": ["u8", 32] } } } - ] - } -} -``` - -Currently generated as an empty array by the macro. Future versions may populate this from account struct definitions. - ---- - -### `types` (section) - -Custom type definitions (struct or enum): - -```json -{ - "kind": "struct", - "fields": [ - { "name": "field_name", "type": "u64" } - ], - "variants": [] -} -``` - -For enums: - -```json -{ - "kind": "enum", - "fields": [], - "variants": [ - { "name": "Active", "fields": [] }, - { "name": "WithData", "fields": [{ "name": "value", "type": "u64" }] } - ] -} -``` - -Currently generated as an empty array by the macro. - ---- - -### `errors` (section) - -Error definitions: - -```json -{ - "code": 1000, - "name": "AccountCountMismatch", - "msg": "Expected {expected} accounts, got {actual}" -} -``` - -Currently generated as an empty array by the macro. The built-in `LezError` variants have fixed codes (see [LezError](#lezerror)). - ---- - -### Discriminators - -Each instruction can have a `discriminator` field — an 8-byte array computed as: - -``` -SHA-256("global:{instruction_name}")[..8] -``` - -This matches the lssa-lang convention. The discriminator is computed at macro expansion time and included in the IDL generated by `__program_idl()`. - -**Example:** For instruction `create`: -``` -SHA-256("global:create")[0..8] = [72, 137, 94, 219, 188, 57, 3, 12] -``` - -The `compute_discriminator()` function in `lez-framework-core/src/idl.rs` and `lez-framework-macros/src/lib.rs` implements this. - ---- - -### `spec` and `metadata` - -lssa-lang compatibility fields: - -```json -{ - "spec": "0.1.0", - "metadata": { - "name": "program_name", - "version": "0.1.0" - } -} -``` - -These are included in the `__program_idl()` output but omitted from the `PROGRAM_IDL_JSON` const string. - ---- - -### `instruction_type` - -When `#[lez_program(instruction = "my_crate::Instruction")]` is used, the IDL includes: - -```json -{ - "instruction_type": "my_crate::Instruction" -} -``` - -This tells `lez-client-gen` to import and use the external type in generated FFI code (instead of generating a local `#[derive(Serialize, Deserialize)]` enum), ensuring correct serialization for the zkVM guest. - ---- - -### Full Example IDL - -```json -{ - "version": "0.1.0", - "name": "my_multisig", - "instructions": [ - { - "name": "create", - "accounts": [ - { - "name": "multisig_state", - "writable": true, - "signer": false, - "init": true, - "pda": { - "seeds": [ - { "kind": "const", "value": "multisig_state__" }, - { "kind": "arg", "path": "create_key" } - ] - } - }, - { - "name": "creator", - "writable": false, - "signer": true, - "init": false - } - ], - "args": [ - { "name": "create_key", "type": { "array": ["u8", 32] } }, - { "name": "threshold", "type": "u64" }, - { "name": "members", "type": { "vec": { "array": ["u8", 32] } } } - ], - "discriminator": [72, 137, 94, 219, 188, 57, 3, 12], - "execution": { "public": true, "private_owned": false }, - "variant": "Create" - }, - { - "name": "approve", - "accounts": [ - { - "name": "multisig_state", - "writable": false, - "signer": false, - "init": false, - "pda": { - "seeds": [ - { "kind": "const", "value": "multisig_state__" } - ] - } - }, - { - "name": "proposal", - "writable": true, - "signer": false, - "init": false - }, - { - "name": "member", - "writable": false, - "signer": true, - "init": false - } - ], - "args": [ - { "name": "proposal_id", "type": "u64" } - ], - "discriminator": [18, 234, 43, 71, 52, 0, 12, 8], - "execution": { "public": true, "private_owned": false }, - "variant": "Approve" - } - ], - "accounts": [], - "types": [], - "errors": [], - "instruction_type": "multisig_core::Instruction" -} -``` - ---- - -## Client Code Generation (lez-client-gen) - -Generates typed Rust client code and C FFI wrappers from LEZ program IDL JSON. Useful for integrating LEZ programs into applications (e.g., C++/Qt desktop apps). - -### lez-client-gen CLI Usage - -```bash -lez-client-gen --idl --out-dir -``` - -| Option | Required | Description | -|--------|----------|-------------| -| `--idl ` | Yes | Path to the IDL JSON file. | -| `--out-dir ` | Yes | Output directory for generated files. Created if it doesn't exist. | -| `--help`, `-h` | | Print help. | - -**Output files** (named after the program): - -``` -/ -├── _client.rs # Typed Rust client -├── _ffi.rs # C FFI wrappers -└── .h # C header file -``` - -**Example:** - -```bash -lez-client-gen --idl multisig-idl.json --out-dir generated/ - -# Output: -# Generated: -# Client: generated/my_multisig_client.rs -# FFI: generated/my_multisig_ffi.rs -# Header: generated/my_multisig.h -``` - -### Library API - -```rust -use lez_client_gen::{generate_from_idl_json, generate_from_idl, CodegenOutput}; - -// From JSON string -let json = std::fs::read_to_string("idl.json")?; -let output: CodegenOutput = generate_from_idl_json(&json)?; - -// From parsed IDL -let idl: LezIdl = serde_json::from_str(&json)?; -let output: CodegenOutput = generate_from_idl(&idl)?; - -// Write output files -std::fs::write("client.rs", &output.client_code)?; -std::fs::write("ffi.rs", &output.ffi_code)?; -std::fs::write("program.h", &output.header)?; -``` - -**`CodegenOutput` fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `client_code` | `String` | Typed Rust client module source code | -| `ffi_code` | `String` | C FFI wrapper source code | -| `header` | `String` | C header file content | - ---- - -### Generated Rust Client - -The generated client includes: - -1. **Instruction enum** — `{ProgramName}Instruction` with all variants -2. **Account structs** — one `{InstructionName}Accounts` struct per instruction, with fields for each account (using `AccountId` for single accounts, `Vec` for rest accounts) -3. **Client struct** — `{ProgramName}Client<'w>` with: - - Constructor: `new(wallet: &WalletCore, program_id: ProgramId)` - - One async method per instruction that builds, signs, and submits the transaction - - PDA helper methods: `compute_{account}_pda(...)` for each PDA account -4. **Helper functions** — `parse_program_id_hex()`, `compute_pda()` - -**Example generated code** (for a multisig program): - -```rust -pub enum MyMultisigInstruction { - Create { create_key: [u8; 32], threshold: u64, members: Vec<[u8; 32]> }, - Approve { proposal_id: u64 }, -} - -pub struct CreateAccounts { - pub multisig_state: AccountId, - pub creator: AccountId, -} - -pub struct MyMultisigClient<'w> { - pub wallet: &'w WalletCore, - pub program_id: ProgramId, -} - -impl<'w> MyMultisigClient<'w> { - pub fn new(wallet: &'w WalletCore, program_id: ProgramId) -> Self { /* ... */ } - pub async fn create(&self, accounts: CreateAccounts, create_key: [u8; 32], ...) -> Result { /* ... */ } - pub async fn approve(&self, accounts: ApproveAccounts, proposal_id: u64) -> Result { /* ... */ } - pub fn compute_multisig_state_pda(create_key: &[u8; 32]) -> AccountId { /* ... */ } -} -``` - ---- - -### Generated C FFI - -The FFI module generates `extern "C"` functions that accept and return JSON strings. Each instruction gets a function: - -```rust -#[no_mangle] -pub extern "C" fn my_multisig_create(args_json: *const c_char) -> *mut c_char { /* ... */ } - -#[no_mangle] -pub extern "C" fn my_multisig_approve(args_json: *const c_char) -> *mut c_char { /* ... */ } - -#[no_mangle] -pub extern "C" fn my_multisig_free_string(s: *mut c_char) { /* ... */ } - -#[no_mangle] -pub extern "C" fn my_multisig_version() -> *mut c_char { /* ... */ } -``` - -**Required JSON fields for every instruction call:** - -| Field | Type | Description | -|-------|------|-------------| -| `wallet_path` | `string` | Path to the NSSA wallet directory | -| `sequencer_url` | `string` | Sequencer URL (e.g., `"http://127.0.0.1:3040"`) | -| `program_id_hex` | `string` | 64-character hex string of the program ID | - -Plus instruction-specific fields for accounts and arguments. - -**Return format:** - -```json -// Success -{ "success": true, "tx_hash": "abc123..." } - -// Error -{ "success": false, "error": "error message" } -``` - -**Instruction type handling:** -- If the IDL has `instruction_type` set, the FFI imports and uses that type directly (`use path::to::Instruction as ProgramInstruction;`) -- Otherwise, a local `#[derive(Serialize, Deserialize)]` enum is generated - -**PDA helpers:** Standalone `compute_{account}_pda()` functions are generated for each unique PDA account. Single-seed PDAs use `PdaSeed` directly; multi-seed PDAs use SHA-256 combination. - ---- - -### Generated C Header - -```c -/* Auto-generated C header for my_multisig FFI. DO NOT EDIT. */ -#ifndef MY_MULTISIG_FFI_H -#define MY_MULTISIG_FFI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/* create instruction */ -char* my_multisig_create(const char* args_json); - -/* approve instruction */ -char* my_multisig_approve(const char* args_json); - -void my_multisig_free_string(char* s); -char* my_multisig_version(void); - -#ifdef __cplusplus -} -#endif - -#endif /* MY_MULTISIG_FFI_H */ -``` - ---- - -### Using from C++/Qt - -1. **Generate the FFI:** - -```bash -lez-client-gen --idl my_program-idl.json --out-dir ffi/ -``` - -2. **Build as a shared library** by adding to your `Cargo.toml`: - -```toml -[lib] -name = "my_program_ffi" -crate-type = ["cdylib"] -``` - -Include the generated FFI code in your lib: - -```rust -// src/lib.rs -include!("../ffi/my_program_ffi.rs"); -``` - -3. **Build:** - -```bash -cargo build --release --lib -# Produces: target/release/libmy_program_ffi.so (Linux) -# target/release/libmy_program_ffi.dylib (macOS) -``` - -4. **Use from C++/Qt:** - -```cpp -#include "my_program.h" -#include -#include - -// Build the JSON arguments -QJsonObject args; -args["wallet_path"] = "/path/to/wallet"; -args["program_id_hex"] = "abc123..."; -args["amount"] = 5; -args["owner"] = "base58-account-id"; - -QByteArray json = QJsonDocument(args).toJson(QJsonDocument::Compact); -char* result = my_program_increment(json.constData()); - -// Parse the result -QJsonDocument resultDoc = QJsonDocument::fromJson(result); -bool success = resultDoc.object()["success"].toBool(); -QString txHash = resultDoc.object()["tx_hash"].toString(); - -// Free the result string -my_program_free_string(result); -``` - ---- - -## Validation - -### Generated Validation Functions - -The `#[lez_program]` macro generates validation functions for instructions that have `signer` or `init` constraints. These run automatically before the handler. - -**Generated function signature:** - -```rust -pub fn __validate_{instruction_name}( - accounts: &[AccountWithMetadata] -) -> Result<(), LezError> -``` - -**Checks performed (in order):** - -1. **Signer checks** — for each `#[account(signer)]`: - ```rust - if !accounts[idx].is_authorized { - return Err(LezError::Unauthorized { - message: "Account '{name}' (index {idx}) must be a signer", - }); - } - ``` - -2. **Init checks** — for each `#[account(init)]`: - ```rust - if accounts[idx].account != Account::default() { - return Err(LezError::AccountAlreadyInitialized { - account_index: idx, - }); - } - ``` - -If an instruction has no `signer` or `init` constraints, no validation function is generated. - ---- - -### Validation Helpers - -The `lez-framework-core::validation` module provides helper functions used by generated code: - -| Function | Signature | Description | -|----------|-----------|-------------| -| `validate_account_count` | `fn(actual: usize, expected: usize) -> Result<(), LezError>` | Check that the correct number of accounts was provided. Returns `AccountCountMismatch` on failure. | -| `validate_accounts` | `fn(account_count: usize, constraints: &[AccountConstraint]) -> Result<(), LezError>` | Validate accounts against constraints. Currently checks count; ownership, init state, signer, and PDA checks are delegated to the macro-generated code. | -| `is_default_account` | `fn(data: &[u8]) -> bool` | Check if account data is empty or all zeros. Used for `init` constraint. | -| `verify_owner` | `fn(account_owner: &[u8; 32], expected_owner: &[u8; 32], account_index: usize) -> Result<(), LezError>` | Verify account ownership. Returns `InvalidAccountOwner` on mismatch. | - ---- - -## Serialization (lez-cli internals) - -The CLI serializes instruction data in **risc0 serde format** (`Vec`) for submission to the zkVM guest. The format is: - -``` -[variant_index: u32, field1_words..., field2_words..., ...] -``` - -**Per-type encoding:** - -| Type | Encoding | -|------|----------| -| `bool` | 1 word: `0` or `1` | -| `u8` | 1 word (zero-extended) | -| `u32` | 1 word | -| `u64` | 2 words (little-endian) | -| `u128` | 4 words (little-endian) | -| `program_id` / `[u32; 8]` | 8 words | -| `[u8; N]` | N words (each byte zero-extended to u32) | -| `String` | `[length: u32, bytes...]` (bytes padded to u32 words) | -| `Vec` | `[length: u32, elements...]` | -| `Option` | `[0]` for None; `[1, value...]` for Some | - -This matches `risc0_zkvm::serde::to_vec` for enum struct variants. - ---- - -## Prelude - -Import the prelude for convenient access to common types: - -```rust -use lez_framework::prelude::*; -``` - -This imports: -- `lez_program` (macro) -- `instruction` (macro) -- `LezOutput` -- `LezError`, `LezResult` -- `AccountConstraint` -- `Account`, `AccountWithMetadata` -- `AccountPostState`, `ChainedCall`, `PdaSeed`, `ProgramId` -- `BorshSerialize`, `BorshDeserialize` diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 00000000..e1fcfe0f --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,15 @@ +# SPEL Framework Reference + +Comprehensive API reference for the SPEL framework (`logos-co/spel`). This document covers every macro, type, CLI command, IDL schema, and code generation feature. + +For a guided walkthrough, see the [Tutorial](../tutorial.md). + +--- + +## Reference Pages + +- [**Macros**](macros.md) — `#[lez_program]`, `#[instruction]`, `generate_idl!`, and generated validation functions +- [**Types**](types.md) — Framework types: `LezOutput`, `LezError`, `AccountConstraint`, `ChainedCall`, `PdaSeed`, and the prelude +- [**CLI**](cli.md) — All `lez-cli` commands (`init`, `inspect`, `idl`, `pda`, instruction execution) with flags, examples, and type format table +- [**IDL Format**](idl.md) — IDL JSON schema, instruction/account/arg definitions, discriminators, and lssa-lang compatibility fields +- [**Client Code Generation**](client-gen.md) — `lez-client-gen` CLI, library API, generated Rust client, C FFI wrappers, C header, and C++/Qt integration example diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 00000000..3d64f143 --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,290 @@ +# CLI + +The `lez-cli` crate provides a generic, IDL-driven command-line interface for any LEZ program. Programs get a complete CLI by writing a three-line wrapper. + +For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). + +--- + +## Quick Start + +```rust +#[tokio::main] +async fn main() { + lez_cli::run().await; +} +``` + +--- + +## Global Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--idl ` | `-i` | Path to the IDL JSON file. Required for most commands. | +| `--program ` | `-p` | Path to the program ELF binary. Used to compute ProgramId and for deployment. Defaults to `program.bin`. | +| `--program-id ` | | 64-character hex string of the program ID. Overrides `--program` for ProgramId resolution (faster, no binary loading). | +| `--dry-run` | | Print parsed/serialized data without submitting the transaction. | +| `--bin- ` | | Additional program binary. Auto-fills `---program-id` from the binary's image ID. Useful for cross-program references. | + +--- + +## `init` + +Scaffold a new LEZ project. + +```bash +lez-cli init +``` + +**Does not require `--idl`.** + +Creates a complete project structure with: +- Workspace `Cargo.toml` +- `{name}_core/` crate for shared types +- `methods/guest/` with a skeleton `#[lez_program]` guest binary +- `examples/` with `generate_idl.rs` and `{name}_cli.rs` +- `Makefile` with `build`, `idl`, `cli`, `deploy`, `inspect`, `setup`, `status`, `clean` targets +- `README.md` with quick start guide +- `.gitignore` + +**Example:** + +```bash +lez-cli init my-token +cd my-token +# Edit methods/guest/src/bin/my_token.rs with your program logic +make idl +make cli ARGS="--help" +``` + +--- + +## `inspect` + +Print the ProgramId for one or more ELF binaries. + +```bash +lez-cli inspect [FILE...] +``` + +**Does not require `--idl`.** + +**Output for each binary:** + +``` +📦 path/to/program.bin + ProgramId (decimal): 12345,67890,11111,22222,33333,44444,55555,66666 + ProgramId (hex): 00003039,000109b2,... + ImageID (hex bytes): 393000009b210100... +``` + +The three formats: +- **Decimal**: comma-separated `[u32; 8]` values +- **Hex**: comma-separated hex `[u32; 8]` values +- **ImageID hex bytes**: 64-character hex string (little-endian byte representation) + +**Example:** + +```bash +lez-cli inspect methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin +``` + +--- + +## `idl` (command) + +Print the loaded IDL as pretty-printed JSON. + +```bash +lez-cli --idl idl +``` + +**Example:** + +```bash +lez-cli -i my_program-idl.json idl +``` + +--- + +## `pda` (IDL mode) + +Compute a PDA address from the IDL-defined seeds. + +```bash +lez-cli --idl [-p | --program-id ] pda [-- ...] +``` + +Looks up the named account across all instructions in the IDL, finds its PDA seed definition, resolves all seeds, and prints the base58 AccountId. + +**Seed resolution:** +- `const` seeds: resolved from the IDL definition +- `arg` seeds: must be provided via `-- ` +- `account` seeds: must be provided via `---account ` + +**ProgramId resolution** (in priority order): +1. `--program-id ` flag +2. Load from `--program ` binary + +**Example:** + +```bash +# Simple PDA with only const seeds +lez-cli -i my_program-idl.json --program-id abc123...def pda counter + +# PDA with arg seed +lez-cli -i multisig-idl.json --program-id abc123...def pda multisig_state \ + --create-key 0a1b2c... + +# List available PDAs +lez-cli -i my_program-idl.json pda +``` + +**If no account name is given**, prints all PDA accounts found in the IDL. + +--- + +## `pda` (raw mode) + +Compute an arbitrary PDA from a program ID and raw seeds — no IDL required. + +```bash +lez-cli --program-id <64-CHAR-HEX> pda [SEED2] ... +``` + +**Does not require `--idl`.** + +Each seed can be: +- **64-character hex string**: interpreted as 32 raw bytes +- **Plain string**: UTF-8 encoded and zero-padded to 32 bytes (max 32 bytes) + +**Multi-seed derivation:** `SHA-256(seed1_32 || seed2_32 || ...)` + +**Output:** base58 AccountId + +**Example:** + +```bash +# Single seed +lez-cli --program-id abc123...def pda my_state_name + +# Multiple seeds +lez-cli --program-id abc123...def pda multisig_vault__ 0a1b2c3d... +``` + +--- + +## Instruction Execution + +Execute any instruction defined in the IDL. The CLI auto-generates subcommands from the IDL. + +```bash +lez-cli --idl [-p | --program-id ] [-- ...] [---account ...] +``` + +Instruction names are converted from `snake_case` to `kebab-case` in CLI commands (e.g., `create_proposal` → `create-proposal`). + +**Arguments:** +- Instruction args: `-- ` (type-aware parsing from IDL) +- Non-PDA accounts: `---account ` (64 hex chars or base58 string) +- PDA accounts: **auto-computed** from seeds — not passed as arguments +- Rest (variadic) accounts: optional, comma-separated list of account IDs + +**Additional program binaries:** Use `--bin- ` to auto-fill `---program-id` from the binary's image ID. + +**Transaction flow:** +1. Parse and validate all arguments +2. Auto-fill program IDs from `--bin-*` flags +3. Serialize instruction data in risc0 serde format +4. Resolve PDA accounts from seeds +5. Initialize wallet from `NSSA_WALLET_HOME_DIR` environment variable +6. Fetch nonces for signer accounts +7. Build, sign, and submit the transaction +8. Poll for confirmation + +**Per-instruction help:** + +```bash +lez-cli --idl --help +``` + +Shows accounts (with flags like `[mut, signer, init]`), PDA status, and argument types. + +**Example:** + +```bash +# Execute a create instruction +lez-cli -i multisig-idl.json -p multisig.bin create \ + --create-key 0a1b2c3d4e5f... \ + --threshold 2 \ + --members "aabb...00,ccdd...00" \ + --creator-account EjR7...base58 + +# Dry run (no submission) +lez-cli -i multisig-idl.json --program-id abc123...def --dry-run approve \ + --proposal-id 5 \ + --proposal-account aabb...00 \ + --member-account ccdd...00 + +# Auto-fill cross-program reference +lez-cli -i treasury-idl.json -p treasury.bin \ + --bin-token token.bin \ + transfer --amount 100 \ + --from-account aabb...00 \ + --to-account ccdd...00 +``` + +--- + +## Type Format Table + +How to pass values for each IDL type on the command line: + +| IDL Type | CLI Format | Example | +|----------|-----------|---------| +| `u8` | Decimal number | `255` | +| `u32` | Decimal number | `1000000` | +| `u64` | Decimal number | `1000000000` | +| `u128` | Decimal number | `340282366920938463463374607431768211455` | +| `bool` | `true`/`false`/`1`/`0`/`yes`/`no` | `true` | +| `string` / `String` | Plain text | `"hello world"` | +| `[u8; N]` | Hex string (`2*N` hex chars) **or** UTF-8 string (≤N chars, zero-padded) | `0a1b2c...` (64 chars for N=32) or `my_string` | +| `[u32; 8]` / `program_id` | 8 comma-separated u32 values, or 64 hex chars | `0,0,0,0,0,0,0,0` or `abc123...def` | +| `Vec<[u8; 32]>` | Comma-separated hex strings | `"aabb...00,ccdd...00"` | +| `Vec` | Comma-separated decimal bytes | `1,2,3,4,5` | +| `Vec` | Comma-separated u32 values | `100,200,300` | +| `Option` | `none`/`null`/empty for None; otherwise same as inner type | `none` or `42` | +| Account IDs | Base58 string **or** 64 hex chars (with optional `0x` prefix) | `EjR7...` or `0xaabb...00` | + +**Notes:** +- `[u8; N]` accepts both hex and string formats. Hex is detected by length (exactly `2*N` chars, all hex digits). Otherwise treated as UTF-8 and zero-padded. +- `0x` prefix is accepted and stripped for hex values. +- `program_id` values can also use `0x`-prefixed hex for individual u32 components. + +--- + +## Serialization (lez-cli internals) + +The CLI serializes instruction data in **risc0 serde format** (`Vec`) for submission to the zkVM guest. The format is: + +``` +[variant_index: u32, field1_words..., field2_words..., ...] +``` + +**Per-type encoding:** + +| Type | Encoding | +|------|----------| +| `bool` | 1 word: `0` or `1` | +| `u8` | 1 word (zero-extended) | +| `u32` | 1 word | +| `u64` | 2 words (little-endian) | +| `u128` | 4 words (little-endian) | +| `program_id` / `[u32; 8]` | 8 words | +| `[u8; N]` | N words (each byte zero-extended to u32) | +| `String` | `[length: u32, bytes...]` (bytes padded to u32 words) | +| `Vec` | `[length: u32, elements...]` | +| `Option` | `[0]` for None; `[1, value...]` for Some | + +This matches `risc0_zkvm::serde::to_vec` for enum struct variants. diff --git a/docs/reference/client-gen.md b/docs/reference/client-gen.md new file mode 100644 index 00000000..01b76f1b --- /dev/null +++ b/docs/reference/client-gen.md @@ -0,0 +1,241 @@ +# Client Code Generation + +`lez-client-gen` generates typed Rust client code and C FFI wrappers from LEZ program IDL JSON. Useful for integrating LEZ programs into applications (e.g., C++/Qt desktop apps). + +For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). + +--- + +## lez-client-gen CLI Usage + +```bash +lez-client-gen --idl --out-dir +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--idl ` | Yes | Path to the IDL JSON file. | +| `--out-dir ` | Yes | Output directory for generated files. Created if it doesn't exist. | +| `--help`, `-h` | | Print help. | + +**Output files** (named after the program): + +``` +/ +├── _client.rs # Typed Rust client +├── _ffi.rs # C FFI wrappers +└── .h # C header file +``` + +**Example:** + +```bash +lez-client-gen --idl multisig-idl.json --out-dir generated/ + +# Output: +# Generated: +# Client: generated/my_multisig_client.rs +# FFI: generated/my_multisig_ffi.rs +# Header: generated/my_multisig.h +``` + +## Library API + +```rust +use lez_client_gen::{generate_from_idl_json, generate_from_idl, CodegenOutput}; + +// From JSON string +let json = std::fs::read_to_string("idl.json")?; +let output: CodegenOutput = generate_from_idl_json(&json)?; + +// From parsed IDL +let idl: LezIdl = serde_json::from_str(&json)?; +let output: CodegenOutput = generate_from_idl(&idl)?; + +// Write output files +std::fs::write("client.rs", &output.client_code)?; +std::fs::write("ffi.rs", &output.ffi_code)?; +std::fs::write("program.h", &output.header)?; +``` + +**`CodegenOutput` fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `client_code` | `String` | Typed Rust client module source code | +| `ffi_code` | `String` | C FFI wrapper source code | +| `header` | `String` | C header file content | + +--- + +## Generated Rust Client + +The generated client includes: + +1. **Instruction enum** — `{ProgramName}Instruction` with all variants +2. **Account structs** — one `{InstructionName}Accounts` struct per instruction, with fields for each account (using `AccountId` for single accounts, `Vec` for rest accounts) +3. **Client struct** — `{ProgramName}Client<'w>` with: + - Constructor: `new(wallet: &WalletCore, program_id: ProgramId)` + - One async method per instruction that builds, signs, and submits the transaction + - PDA helper methods: `compute_{account}_pda(...)` for each PDA account +4. **Helper functions** — `parse_program_id_hex()`, `compute_pda()` + +**Example generated code** (for a multisig program): + +```rust +pub enum MyMultisigInstruction { + Create { create_key: [u8; 32], threshold: u64, members: Vec<[u8; 32]> }, + Approve { proposal_id: u64 }, +} + +pub struct CreateAccounts { + pub multisig_state: AccountId, + pub creator: AccountId, +} + +pub struct MyMultisigClient<'w> { + pub wallet: &'w WalletCore, + pub program_id: ProgramId, +} + +impl<'w> MyMultisigClient<'w> { + pub fn new(wallet: &'w WalletCore, program_id: ProgramId) -> Self { /* ... */ } + pub async fn create(&self, accounts: CreateAccounts, create_key: [u8; 32], ...) -> Result { /* ... */ } + pub async fn approve(&self, accounts: ApproveAccounts, proposal_id: u64) -> Result { /* ... */ } + pub fn compute_multisig_state_pda(create_key: &[u8; 32]) -> AccountId { /* ... */ } +} +``` + +--- + +## Generated C FFI + +The FFI module generates `extern "C"` functions that accept and return JSON strings. Each instruction gets a function: + +```rust +#[no_mangle] +pub extern "C" fn my_multisig_create(args_json: *const c_char) -> *mut c_char { /* ... */ } + +#[no_mangle] +pub extern "C" fn my_multisig_approve(args_json: *const c_char) -> *mut c_char { /* ... */ } + +#[no_mangle] +pub extern "C" fn my_multisig_free_string(s: *mut c_char) { /* ... */ } + +#[no_mangle] +pub extern "C" fn my_multisig_version() -> *mut c_char { /* ... */ } +``` + +**Required JSON fields for every instruction call:** + +| Field | Type | Description | +|-------|------|-------------| +| `wallet_path` | `string` | Path to the NSSA wallet directory | +| `sequencer_url` | `string` | Sequencer URL (e.g., `"http://127.0.0.1:3040"`) | +| `program_id_hex` | `string` | 64-character hex string of the program ID | + +Plus instruction-specific fields for accounts and arguments. + +**Return format:** + +```json +// Success +{ "success": true, "tx_hash": "abc123..." } + +// Error +{ "success": false, "error": "error message" } +``` + +**Instruction type handling:** +- If the IDL has `instruction_type` set, the FFI imports and uses that type directly (`use path::to::Instruction as ProgramInstruction;`) +- Otherwise, a local `#[derive(Serialize, Deserialize)]` enum is generated + +**PDA helpers:** Standalone `compute_{account}_pda()` functions are generated for each unique PDA account. Single-seed PDAs use `PdaSeed` directly; multi-seed PDAs use SHA-256 combination. + +--- + +## Generated C Header + +```c +/* Auto-generated C header for my_multisig FFI. DO NOT EDIT. */ +#ifndef MY_MULTISIG_FFI_H +#define MY_MULTISIG_FFI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* create instruction */ +char* my_multisig_create(const char* args_json); + +/* approve instruction */ +char* my_multisig_approve(const char* args_json); + +void my_multisig_free_string(char* s); +char* my_multisig_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* MY_MULTISIG_FFI_H */ +``` + +--- + +## Using from C++/Qt + +1. **Generate the FFI:** + +```bash +lez-client-gen --idl my_program-idl.json --out-dir ffi/ +``` + +2. **Build as a shared library** by adding to your `Cargo.toml`: + +```toml +[lib] +name = "my_program_ffi" +crate-type = ["cdylib"] +``` + +Include the generated FFI code in your lib: + +```rust +// src/lib.rs +include!("../ffi/my_program_ffi.rs"); +``` + +3. **Build:** + +```bash +cargo build --release --lib +# Produces: target/release/libmy_program_ffi.so (Linux) +# target/release/libmy_program_ffi.dylib (macOS) +``` + +4. **Use from C++/Qt:** + +```cpp +#include "my_program.h" +#include +#include + +// Build the JSON arguments +QJsonObject args; +args["wallet_path"] = "/path/to/wallet"; +args["program_id_hex"] = "abc123..."; +args["amount"] = 5; +args["owner"] = "base58-account-id"; + +QByteArray json = QJsonDocument(args).toJson(QJsonDocument::Compact); +char* result = my_program_increment(json.constData()); + +// Parse the result +QJsonDocument resultDoc = QJsonDocument::fromJson(result); +bool success = resultDoc.object()["success"].toBool(); +QString txHash = resultDoc.object()["tx_hash"].toString(); + +// Free the result string +my_program_free_string(result); +``` diff --git a/docs/reference/idl.md b/docs/reference/idl.md new file mode 100644 index 00000000..4f97eeec --- /dev/null +++ b/docs/reference/idl.md @@ -0,0 +1,381 @@ +# IDL Format + +The IDL (Interface Definition Language) is a JSON file that describes a LEZ program's complete interface. It is generated by the `generate_idl!` macro or the `__program_idl()` function. + +For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). + +--- + +## Top-Level Schema + +```json +{ + "version": "0.1.0", + "name": "program_name", + "instructions": [ /* ... */ ], + "accounts": [], + "types": [], + "errors": [], + "spec": "0.1.0", + "metadata": { + "name": "program_name", + "version": "0.1.0" + }, + "instruction_type": "my_crate::Instruction" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `version` | `string` | Yes | IDL version. Currently `"0.1.0"`. | +| `name` | `string` | Yes | Program name (from the module name). | +| `instructions` | `array` | Yes | List of instruction definitions. | +| `accounts` | `array` | Yes | Account type definitions (currently unused, always `[]`). | +| `types` | `array` | Yes | Custom type definitions (currently unused, always `[]`). | +| `errors` | `array` | Yes | Error definitions (currently unused, always `[]`). | +| `spec` | `string` | No | IDL spec version identifier (lssa-lang compat). | +| `metadata` | `object` | No | Program metadata with `name` and `version` (lssa-lang compat). | +| `instruction_type` | `string` | No | Fully-qualified Rust path to external instruction enum. When set, generated FFI imports this type. E.g., `"multisig_core::Instruction"`. | + +--- + +## `instructions` + +Each instruction object: + +```json +{ + "name": "create_proposal", + "accounts": [ /* ... */ ], + "args": [ /* ... */ ], + "discriminator": [72, 137, 94, 219, 188, 57, 3, 12], + "execution": { "public": true, "private_owned": false }, + "variant": "CreateProposal" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | Yes | Instruction name in `snake_case`. | +| `accounts` | `array` | Yes | Accounts expected by this instruction (in order). | +| `args` | `array` | Yes | Instruction arguments. | +| `discriminator` | `array` | No | SHA-256("global:{name}")[..8] — 8-byte discriminator (lssa-lang compat). | +| `execution` | `object` | No | `{ "public": bool, "private_owned": bool }` — execution mode (lssa-lang compat). Defaults to public. | +| `variant` | `string` | No | PascalCase variant name in the `Instruction` enum (lssa-lang compat). | + +--- + +## `accounts` (in instructions) + +Each account object within an instruction: + +```json +{ + "name": "multisig_state", + "writable": true, + "signer": false, + "init": true, + "owner": null, + "pda": { + "seeds": [ + { "kind": "const", "value": "multisig_state__" }, + { "kind": "arg", "path": "create_key" } + ] + }, + "rest": false, + "visibility": ["public"] +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | `string` | | Account name from the function parameter. | +| `writable` | `bool` | `false` | Whether the account is modified by this instruction. Set by `mut` or `init`. | +| `signer` | `bool` | `false` | Whether the account must sign the transaction. | +| `init` | `bool` | `false` | Whether this is a new account being initialized. | +| `owner` | `string?` | `null` | Expected owner program ID (hex string), or null. | +| `pda` | `object?` | `null` | PDA derivation specification, or null for non-PDA accounts. | +| `rest` | `bool` | `false` | If true, this account represents a variable-length trailing account list (`Vec`). | +| `visibility` | `array` | `[]` | Visibility tags (lssa-lang compat). Typically `["public"]`. | + +--- + +## `args` + +Each argument object: + +```json +{ + "name": "threshold", + "type": "u64" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Argument name in `snake_case`. | +| `type` | `IdlType` | The argument type. See [IDL Types](#idl-types). | + +--- + +## `pda` + +PDA derivation specification: + +```json +{ + "seeds": [ + { "kind": "const", "value": "multisig_state__" }, + { "kind": "account", "path": "user" }, + { "kind": "arg", "path": "create_key" } + ] +} +``` + +**Seed kinds:** + +| Kind | Fields | Description | +|------|--------|-------------| +| `const` | `value: string` | Constant string seed. UTF-8 bytes, zero-padded to 32 bytes. | +| `account` | `path: string` | References another account by name. Uses the account's 32-byte ID. | +| `arg` | `path: string` | References an instruction argument by name. Value is converted to 32 bytes (type-dependent). | + +**Derivation:** +- Single seed: used directly as a 32-byte PDA seed +- Multiple seeds: `SHA-256(seed1_bytes || seed2_bytes || ...)` → 32-byte combined seed +- Final: `AccountId::from((program_id, &PdaSeed::new(combined_seed)))` + +--- + +## IDL Types + +Types in the IDL are represented as JSON using an untagged format: + +| Rust Type | IDL JSON | Description | +|-----------|----------|-------------| +| `u8` | `"u8"` | Primitive string | +| `u16` | `"u16"` | Primitive string | +| `u32` | `"u32"` | Primitive string | +| `u64` | `"u64"` | Primitive string | +| `u128` | `"u128"` | Primitive string | +| `i8`–`i128` | `"i8"`–`"i128"` | Signed integer primitives | +| `bool` | `"bool"` | Primitive string | +| `String` | `"string"` | Primitive string | +| `ProgramId` | `"program_id"` | Alias for `[u32; 8]` | +| `AccountId` | `"account_id"` | Alias for `[u8; 32]` | +| `Vec` | `{ "vec": }` | Vector of inner type | +| `Option` | `{ "option": }` | Optional inner type | +| `[T; N]` | `{ "array": [, N] }` | Fixed-size array | +| Custom type | `{ "defined": "TypeName" }` | Reference to a named type | + +**Examples:** + +```json +"u64" +{ "vec": "u8" } +{ "vec": { "array": ["u8", 32] } } +{ "option": "string" } +{ "array": ["u8", 32] } +{ "defined": "MyCustomStruct" } +``` + +--- + +## `accounts` (top-level) + +Top-level account type definitions. Each entry describes a named account struct: + +```json +{ + "name": "MultisigState", + "type": { + "kind": "struct", + "fields": [ + { "name": "threshold", "type": "u64" }, + { "name": "members", "type": { "vec": { "array": ["u8", 32] } } } + ] + } +} +``` + +Currently generated as an empty array by the macro. Future versions may populate this from account struct definitions. + +--- + +## `types` (section) + +Custom type definitions (struct or enum): + +```json +{ + "kind": "struct", + "fields": [ + { "name": "field_name", "type": "u64" } + ], + "variants": [] +} +``` + +For enums: + +```json +{ + "kind": "enum", + "fields": [], + "variants": [ + { "name": "Active", "fields": [] }, + { "name": "WithData", "fields": [{ "name": "value", "type": "u64" }] } + ] +} +``` + +Currently generated as an empty array by the macro. + +--- + +## `errors` (section) + +Error definitions: + +```json +{ + "code": 1000, + "name": "AccountCountMismatch", + "msg": "Expected {expected} accounts, got {actual}" +} +``` + +Currently generated as an empty array by the macro. The built-in `LezError` variants have fixed codes (see [Types — LezError](types.md#lezerror)). + +--- + +## Discriminators + +Each instruction can have a `discriminator` field — an 8-byte array computed as: + +``` +SHA-256("global:{instruction_name}")[..8] +``` + +This matches the lssa-lang convention. The discriminator is computed at macro expansion time and included in the IDL generated by `__program_idl()`. + +**Example:** For instruction `create`: +``` +SHA-256("global:create")[0..8] = [72, 137, 94, 219, 188, 57, 3, 12] +``` + +The `compute_discriminator()` function in `lez-framework-core/src/idl.rs` and `lez-framework-macros/src/lib.rs` implements this. + +--- + +## `spec` and `metadata` + +lssa-lang compatibility fields: + +```json +{ + "spec": "0.1.0", + "metadata": { + "name": "program_name", + "version": "0.1.0" + } +} +``` + +These are included in the `__program_idl()` output but omitted from the `PROGRAM_IDL_JSON` const string. + +--- + +## `instruction_type` + +When `#[lez_program(instruction = "my_crate::Instruction")]` is used, the IDL includes: + +```json +{ + "instruction_type": "my_crate::Instruction" +} +``` + +This tells `lez-client-gen` to import and use the external type in generated FFI code (instead of generating a local `#[derive(Serialize, Deserialize)]` enum), ensuring correct serialization for the zkVM guest. + +--- + +## Full Example IDL + +```json +{ + "version": "0.1.0", + "name": "my_multisig", + "instructions": [ + { + "name": "create", + "accounts": [ + { + "name": "multisig_state", + "writable": true, + "signer": false, + "init": true, + "pda": { + "seeds": [ + { "kind": "const", "value": "multisig_state__" }, + { "kind": "arg", "path": "create_key" } + ] + } + }, + { + "name": "creator", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { "name": "create_key", "type": { "array": ["u8", 32] } }, + { "name": "threshold", "type": "u64" }, + { "name": "members", "type": { "vec": { "array": ["u8", 32] } } } + ], + "discriminator": [72, 137, 94, 219, 188, 57, 3, 12], + "execution": { "public": true, "private_owned": false }, + "variant": "Create" + }, + { + "name": "approve", + "accounts": [ + { + "name": "multisig_state", + "writable": false, + "signer": false, + "init": false, + "pda": { + "seeds": [ + { "kind": "const", "value": "multisig_state__" } + ] + } + }, + { + "name": "proposal", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "member", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { "name": "proposal_id", "type": "u64" } + ], + "discriminator": [18, 234, 43, 71, 52, 0, 12, 8], + "execution": { "public": true, "private_owned": false }, + "variant": "Approve" + } + ], + "accounts": [], + "types": [], + "errors": [], + "instruction_type": "multisig_core::Instruction" +} +``` diff --git a/docs/reference/macros.md b/docs/reference/macros.md new file mode 100644 index 00000000..747e174f --- /dev/null +++ b/docs/reference/macros.md @@ -0,0 +1,276 @@ +# Macros + +Attribute macros and proc macros provided by the SPEL framework for defining LEZ programs, instructions, and generating IDL. + +For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). + +--- + +## `#[lez_program]` + +**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) + +Attribute macro applied to a module. Transforms a module of `#[instruction]` functions into a complete LEZ guest binary with dispatch, validation, and IDL generation. + +### Syntax + +```rust +#[lez_program] +mod my_program { + #[instruction] + pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } +} +``` + +With external instruction enum: + +```rust +#[lez_program(instruction = "my_crate::Instruction")] +mod my_program { + #[instruction] + pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } +} +``` + +### Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `instruction` | `"path::to::Enum"` | Optional. External instruction enum path. When set, the macro imports this type instead of generating its own `Instruction` enum. Used when the enum must be shared between on-chain and off-chain code (e.g., for correct borsh serialization in FFI). | + +### What It Generates + +1. **`Instruction` enum** — a `#[derive(Debug, Clone, Serialize, Deserialize)]` enum with one variant per `#[instruction]` function. Variant names are PascalCase conversions of function names. Only non-account parameters become enum fields. Skipped if `instruction = "..."` attribute is set. + +2. **`fn main()`** — the zkVM guest entry point (gated by `#[cfg(not(test))]`). Reads `ProgramInput` from the host, dispatches to the correct handler via `match`, and writes outputs via `write_nssa_outputs_with_chained_call`. Account destructuring from `pre_states` is generated per-instruction. + +3. **Validation functions** — one `__validate_{fn_name}()` function per instruction that has `signer` or `init` constraints. These run before the handler and return `LezError` on failure. + +4. **`PROGRAM_IDL_JSON`** — a `pub const &str` containing the complete IDL as JSON. Available at compile time in any build target. + +5. **`__program_idl()`** — a function returning a constructed `LezIdl` struct (with discriminators, execution metadata, etc.). + +### Constraints + +- The module **must have a body** (not `mod foo;`). +- The module must contain **at least one** `#[instruction]` function. +- Instruction functions **cannot have `self`** parameters. +- Account parameters must be typed `AccountWithMetadata` or `Vec`. + +### Example + +```rust +use lez_framework::prelude::*; +use nssa_core::account::AccountWithMetadata; +use nssa_core::program::AccountPostState; + +#[lez_program] +mod treasury { + use super::*; + + #[instruction] + pub fn deposit( + #[account(mut, pda = literal("vault"))] + vault: AccountWithMetadata, + #[account(signer)] + depositor: AccountWithMetadata, + amount: u128, + ) -> LezResult { + // ... business logic ... + Ok(LezOutput::states_only(vec![ + AccountPostState::new(vault.account.clone()), + AccountPostState::new(depositor.account.clone()), + ])) + } +} +``` + +This generates: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Instruction { + Deposit { amount: u128 }, +} +``` + +--- + +## `#[instruction]` + +**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) + +Marker attribute for functions inside an `#[lez_program]` module. This attribute is processed by `#[lez_program]` — it is a no-op when used standalone. + +### Function Signature Requirements + +```rust +#[instruction] +pub fn instruction_name( + // Account parameters — must come first + #[account(/* constraints */)] + account_name: AccountWithMetadata, + + // Variable-length accounts (at most one, must be last account param) + #[account(/* constraints */)] + rest_accounts: Vec, + + // Instruction arguments — after all account params + arg1: u64, + arg2: String, +) -> LezResult { + // ... +} +``` + +- **Return type** must be `LezResult` (alias for `Result`). +- **Account parameters** are identified by type: `AccountWithMetadata` for single accounts, `Vec` for variable-length. +- **All other parameters** are instruction arguments and become fields in the generated `Instruction` enum variant. +- Function names use `snake_case`; generated enum variants use `PascalCase`. + +### Account Constraint Attributes + +Applied via `#[account(...)]` on account parameters: + +| Constraint | Syntax | Description | +|------------|--------|-------------| +| `signer` | `#[account(signer)]` | Requires `is_authorized == true`. Generates a runtime check before the handler runs. | +| `init` | `#[account(init)]` | Account must be uninitialized (`== Account::default()`). **Implies `mut`**. Used with `AccountPostState::new_claimed()`. | +| `mut` | `#[account(mut)]` | Account is writable. Sets `writable: true` in the IDL. | +| `owner` | `#[account(owner = EXPR)]` | Account must be owned by the given program ID. The expression should resolve to `[u8; 32]`. | +| `pda` | `#[account(pda = SEED)]` | Account address is a PDA derived from the program ID and seed(s). See [PDA Seeds](#pda-seeds) below. Sets the `pda` field in the IDL. | +| `rest` | _(implicit)_ | Not an explicit attribute. When the type is `Vec`, the account is treated as variable-length (`rest: true` in the IDL). | + +Constraints can be combined: + +```rust +#[account(init, signer, pda = literal("state"))] +state: AccountWithMetadata, + +#[account(mut, owner = TOKEN_PROGRAM_ID)] +token_account: AccountWithMetadata, +``` + +### PDA Seeds + +The `pda` attribute specifies how the account address is derived. Seed types: + +| Seed Type | Syntax | Description | +|-----------|--------|-------------| +| `const` | `literal("string")` or `seed_const("string")` | Constant string, UTF-8 encoded and zero-padded to 32 bytes. Aliases: `const(...)`, `r#const(...)`, `seed_const(...)`, `literal(...)`. | +| `account` | `account("other_account_name")` | Uses another account's 32-byte ID as the seed. | +| `arg` | `arg("argument_name")` | Uses an instruction argument's value as the seed. | + +**Single seed:** + +```rust +#[account(pda = literal("my_state"))] +``` + +**Multiple seeds** (array syntax): + +```rust +#[account(pda = [literal("multisig_state__"), arg("create_key")])] +#[account(pda = [literal("vault"), account("user")])] +#[account(pda = [literal("holding"), account("token"), account("user")])] +``` + +**PDA derivation logic:** +- Each seed is resolved to 32 bytes (strings are zero-padded) +- Single seed: used directly as `PdaSeed` +- Multiple seeds: combined via `SHA-256(seed1_32 || seed2_32 || ...)` into a single 32-byte seed +- Final address: `AccountId::from((program_id, &PdaSeed::new(combined)))` + +--- + +## `generate_idl!` + +**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) + +Proc macro that reads a Rust source file at compile time, finds the `#[lez_program]` module, and generates a `fn main()` that prints the complete IDL as JSON. + +### Syntax + +```rust +lez_framework::generate_idl!("path/to/program.rs"); +``` + +The path is resolved relative to `CARGO_MANIFEST_DIR`. The file must contain exactly one `#[lez_program]` module. + +### What It Generates + +A `fn main()` that: +1. Includes the source file via `include_str!` for cargo dependency tracking +2. Parses the embedded IDL JSON string +3. Pretty-prints it to stdout + +### Usage + +Create a binary crate (e.g., `examples/src/bin/generate_idl.rs`): + +```rust +/// Generate IDL JSON for the my_program program. +/// +/// Usage: +/// cargo run --bin generate_idl > my_program-idl.json +lez_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); +``` + +Then run: + +```bash +cargo run --bin generate_idl > my_program-idl.json +``` + +The macro detects the `instruction = "..."` attribute on `#[lez_program(...)]` and includes the `instruction_type` field in the generated IDL when present. + +--- + +## Validation + +### Generated Validation Functions + +The `#[lez_program]` macro generates validation functions for instructions that have `signer` or `init` constraints. These run automatically before the handler. + +**Generated function signature:** + +```rust +pub fn __validate_{instruction_name}( + accounts: &[AccountWithMetadata] +) -> Result<(), LezError> +``` + +**Checks performed (in order):** + +1. **Signer checks** — for each `#[account(signer)]`: + ```rust + if !accounts[idx].is_authorized { + return Err(LezError::Unauthorized { + message: "Account '{name}' (index {idx}) must be a signer", + }); + } + ``` + +2. **Init checks** — for each `#[account(init)]`: + ```rust + if accounts[idx].account != Account::default() { + return Err(LezError::AccountAlreadyInitialized { + account_index: idx, + }); + } + ``` + +If an instruction has no `signer` or `init` constraints, no validation function is generated. + +--- + +### Validation Helpers + +The `lez-framework-core::validation` module provides helper functions used by generated code: + +| Function | Signature | Description | +|----------|-----------|-------------| +| `validate_account_count` | `fn(actual: usize, expected: usize) -> Result<(), LezError>` | Check that the correct number of accounts was provided. Returns `AccountCountMismatch` on failure. | +| `validate_accounts` | `fn(account_count: usize, constraints: &[AccountConstraint]) -> Result<(), LezError>` | Validate accounts against constraints. Currently checks count; ownership, init state, signer, and PDA checks are delegated to the macro-generated code. | +| `is_default_account` | `fn(data: &[u8]) -> bool` | Check if account data is empty or all zeros. Used for `init` constraint. | +| `verify_owner` | `fn(account_owner: &[u8; 32], expected_owner: &[u8; 32], account_index: usize) -> Result<(), LezError>` | Verify account ownership. Returns `InvalidAccountOwner` on mismatch. | diff --git a/docs/reference/types.md b/docs/reference/types.md new file mode 100644 index 00000000..cd5987a6 --- /dev/null +++ b/docs/reference/types.md @@ -0,0 +1,184 @@ +# Types + +Core types provided by `lez-framework-core` for building LEZ programs. These include return types, error types, account constraint metadata, and re-exported types from `nssa_core`. + +For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). + +--- + +## `LezOutput` + +Return value from instruction handlers. Contains post-states and optional chained calls. + +```rust +pub struct LezOutput { + pub post_states: Vec, + pub chained_calls: Vec, +} +``` + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `states_only` | `fn states_only(post_states: Vec) -> Self` | Create output with only post-states and no chained calls. Most common case. | +| `with_chained_calls` | `fn with_chained_calls(post_states: Vec, chained_calls: Vec) -> Self` | Create output with both post-states and chained calls (cross-program invocation). | +| `empty` | `fn empty() -> Self` | Create an empty output (no states, no calls). | +| `into_parts` | `fn into_parts(self) -> (Vec, Vec)` | Destructure into the tuple form expected by `write_nssa_outputs_with_chained_call`. Used by generated code. | + +### Example + +```rust +// Most instructions: just return updated states +Ok(LezOutput::states_only(vec![ + AccountPostState::new_claimed(new_account), // init + AccountPostState::new(updated_account), // update +])) + +// Cross-program call +Ok(LezOutput::with_chained_calls( + vec![AccountPostState::new(state.account.clone())], + vec![chained_call], +)) +``` + +--- + +## `LezResult` + +Type alias for instruction handler return types: + +```rust +pub type LezResult = Result; +``` + +All `#[instruction]` functions must return `LezResult`. + +--- + +## `LezError` + +Structured error type for LEZ programs. Borsh-serializable for on-chain representation. + +```rust +#[derive(Error, Debug, BorshSerialize, BorshDeserialize)] +pub enum LezError { /* ... */ } +``` + +### Variants + +| Variant | Error Code | Fields | Description | +|---------|-----------|--------|-------------| +| `AccountCountMismatch` | 1000 | `expected: usize, actual: usize` | Wrong number of accounts provided | +| `InvalidAccountOwner` | 1001 | `account_index: usize, expected_owner: String` | Account not owned by expected program | +| `AccountAlreadyInitialized` | 1002 | `account_index: usize` | `init` account already has data | +| `AccountNotInitialized` | 1003 | `account_index: usize` | Account expected to be initialized but is empty | +| `InsufficientBalance` | 1004 | `available: u128, requested: u128` | Insufficient balance for operation | +| `DeserializationError` | 1005 | `account_index: usize, message: String` | Failed to deserialize account data | +| `SerializationError` | 1006 | `message: String` | Failed to serialize data | +| `Overflow` | 1007 | `operation: String` | Arithmetic overflow | +| `Unauthorized` | 1008 | `message: String` | Authorization/signer check failed | +| `PdaMismatch` | 1009 | `account_index: usize` | PDA derivation did not match | +| `Custom` | 6000 + code | `code: u32, message: String` | Program-specific error | + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `custom` | `fn custom(code: u32, message: impl Into) -> Self` | Create a custom error. Numeric code starts at 6000. | +| `error_code` | `fn error_code(&self) -> u32` | Get the numeric error code for client-side handling. | + +### Example + +```rust +// Using built-in variants +if balance < amount { + return Err(LezError::InsufficientBalance { + available: balance, + requested: amount, + }); +} + +// Using custom errors +return Err(LezError::custom(1, "Proposal already executed")); +// error_code() returns 6001 +``` + +--- + +## `AccountConstraint` + +Internal type used by the macro-generated validation code. Not typically used directly. + +```rust +pub struct AccountConstraint { + pub mutable: bool, + pub init: bool, + pub owner: Option<[u8; 32]>, + pub signer: bool, + pub seeds: Option>>, +} +``` + +--- + +## `InstructionMeta` / `AccountMeta` / `ArgMeta` + +Metadata types used for IDL generation. Not typically used directly. + +```rust +pub struct InstructionMeta { + pub name: String, + pub accounts: Vec, + pub args: Vec, +} + +pub struct AccountMeta { + pub name: String, + pub writable: bool, + pub init: bool, + pub owner: Option, + pub signer: bool, + pub pda_seeds: Option>, +} + +pub struct ArgMeta { + pub name: String, + pub type_name: String, +} +``` + +--- + +## Re-exported nssa_core types + +The following types are re-exported through `lez-framework-core::prelude`: + +| Type | Origin | Description | +|------|--------|-------------| +| `Account` | `nssa_core::account` | Generic account wrapper | +| `AccountWithMetadata` | `nssa_core::account` | Account data plus `account_id` and `is_authorized` flag. Primary type used in instruction handlers. | +| `AccountPostState` | `nssa_core::program` | Represents the state of an account after instruction execution. Use `new()` for existing accounts, `new_claimed()` for newly initialized accounts. | +| `ChainedCall` | `nssa_core::program` | Cross-program invocation data. Returned in `LezOutput::with_chained_calls()`. | +| `PdaSeed` | `nssa_core::program` | A 32-byte PDA seed. Constructed via `PdaSeed::new(bytes)`. | +| `ProgramId` | `nssa_core::program` | Type alias for `[u32; 8]`. Identifies a program by its RISC Zero image ID. | + +--- + +## Prelude + +Import the prelude for convenient access to common types: + +```rust +use lez_framework::prelude::*; +``` + +This imports: +- `lez_program` (macro) +- `instruction` (macro) +- `LezOutput` +- `LezError`, `LezResult` +- `AccountConstraint` +- `Account`, `AccountWithMetadata` +- `AccountPostState`, `ChainedCall`, `PdaSeed`, `ProgramId` +- `BorshSerialize`, `BorshDeserialize` diff --git a/docs/tutorial.md b/docs/tutorial.md index db852d8a..ee9b3982 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -736,7 +736,7 @@ cargo build --release --lib ## Next Steps -- **Read the [Reference](reference.md)** for complete API documentation +- **Read the [Reference](reference/README.md)** for complete API documentation - **Study [lez-multisig](https://github.com/logos-co/lez-multisig)** for a production-quality example with multi-seed PDAs, variable-length accounts, and external instruction enums - **Generate client code** with `lez-client-gen` for integrating your program into applications - **Write tests** — the `#[cfg(not(test))]` gate on `main()` means your handlers are directly callable in host-side tests: From ae98f12126ab401e7b5a81393e3437c06219ecff Mon Sep 17 00:00:00 2001 From: Jimmy Claw Date: Tue, 10 Mar 2026 06:09:33 +0000 Subject: [PATCH 4/7] feat: add SPEL AgentSkill for OpenClaw/Codex Add an AgentSkill that provides SPEL framework knowledge for building, deploying, and interacting with LEZ on-chain programs. Co-Authored-By: Claude Opus 4.6 --- skills/spel/SKILL.md | 87 +++++++++++ skills/spel/references/cli-ref.md | 189 +++++++++++++++++++++++ skills/spel/references/gotchas.md | 168 +++++++++++++++++++++ skills/spel/references/quickstart.md | 218 +++++++++++++++++++++++++++ 4 files changed, 662 insertions(+) create mode 100644 skills/spel/SKILL.md create mode 100644 skills/spel/references/cli-ref.md create mode 100644 skills/spel/references/gotchas.md create mode 100644 skills/spel/references/quickstart.md diff --git a/skills/spel/SKILL.md b/skills/spel/SKILL.md new file mode 100644 index 00000000..64d1500f --- /dev/null +++ b/skills/spel/SKILL.md @@ -0,0 +1,87 @@ +--- +name: spel +description: "Build, deploy, and interact with LEZ on-chain programs using the SPEL framework (logos-co/spel). Use when: (1) creating a new LEZ program or SPEL project, (2) writing #[lez_program] instructions with account constraints, PDA derivation, or signer checks, (3) generating IDL from program source, (4) using lez-cli to deploy, inspect, call instructions, or compute PDAs, (5) generating typed Rust/C FFI client code with lez-client-gen, (6) debugging SPEL macro output, account validation, or PDA mismatches, (7) registering a program in SPELbook, or (8) any mention of lez-cli, lez_framework, lez-client-gen, #[lez_program], #[instruction], LezOutput, LezError, LezResult, generate_idl!, AccountPostState, PdaSeed, or RISC Zero zkVM guest programs in the LEZ/NSSA ecosystem." +--- + +# SPEL Framework + +SPEL is a Rust framework for building on-chain programs that run on LEZ (the NSSA execution layer). It provides attribute macros (`#[lez_program]`, `#[instruction]`) that generate zkVM guest binaries, instruction dispatch, account validation, IDL, and CLI tooling from annotated Rust modules. + +Programs compile to RISC Zero zkVM guests. The framework auto-generates an `Instruction` enum, `main()` dispatch, validation functions, and a full IDL (JSON). The `lez-cli` reads the IDL at runtime to provide a complete CLI for any program. + +## References + +Read these files as needed: + +- **[references/quickstart.md](references/quickstart.md)** — Full scaffold-to-deploy workflow with real commands. Read when building a new program or recalling the build/deploy/call sequence. +- **[references/gotchas.md](references/gotchas.md)** — Hard-won lessons and common mistakes. Read before writing or debugging any SPEL program. +- **[references/cli-ref.md](references/cli-ref.md)** — CLI cheatsheet for `lez-cli` and `lez-client-gen`. Read when constructing CLI commands or checking flag names. + +## Core Workflow + +1. **Scaffold** — `lez-cli init ` creates workspace with guest binary, core crate, IDL generator, CLI wrapper, and Makefile. +2. **Define state** — Put shared types in `{name}_core/src/lib.rs` (must derive `Serialize`, `Deserialize`). +3. **Write instructions** — In `methods/guest/src/bin/{name}.rs`, use `#[lez_program]` + `#[instruction]` with account constraints (`signer`, `init`, `mut`, `pda`, `owner`). +4. **Build** — `make build` compiles the RISC Zero zkVM guest binary. +5. **Generate IDL** — `make idl` runs `generate_idl!` macro to emit `{name}-idl.json`. +6. **Deploy** — `make setup && make deploy` creates signer account and deploys binary. +7. **Call instructions** — `make cli ARGS="..."` with IDL-driven subcommands. +8. **Generate client code** — `lez-client-gen --idl --out-dir ` for Rust client + C FFI + C header. + +## Key Instruction Patterns + +### Account constraints + +```rust +#[account(signer)] // must sign transaction +#[account(init)] // new account, must be default — implies mut +#[account(mut)] // writable +#[account(pda = literal("x"))] // PDA from constant seed +#[account(pda = account("u"))] // PDA from another account's ID +#[account(pda = arg("key"))] // PDA from instruction argument +#[account(pda = [literal("vault"), account("user")])] // multi-seed PDA +#[account(owner = PROGRAM_ID)] // ownership check +``` + +### Return values + +```rust +// New account (init) +AccountPostState::new_claimed(account) + +// Updated existing account +AccountPostState::new(account) + +// Return with no chained calls (most common) +Ok(LezOutput::states_only(vec![...])) + +// Return with cross-program calls +Ok(LezOutput::with_chained_calls(vec![...], vec![chained_call])) +``` + +### Variable-length accounts + +```rust +#[account(signer)] +members: Vec // variadic trailing accounts +``` + +### External instruction enum (for shared FFI types) + +```rust +#[lez_program(instruction = "my_core::Instruction")] +mod my_program { ... } +``` + +## Critical Rules + +1. **Never edit IDL JSON by hand** — always regenerate via `generate_idl!` / `make idl`. +2. **PDA accounts are auto-computed** — never pass them as CLI arguments; the CLI derives them from seeds + program ID. +3. **`init` implies `mut`** — do not add both; use `AccountPostState::new_claimed()` for init accounts. +4. **Account parameters must come before instruction arguments** in function signatures. +5. **Return ALL accounts** in `post_states` — every account passed to the instruction must appear in the output (even unchanged ones). +6. **Only the owning program can decrease an account's balance** — this is enforced by the runtime. +7. **`generate_idl!` path is relative to `CARGO_MANIFEST_DIR`** — typically `"../methods/guest/src/bin/{name}.rs"`. +8. **Instruction names**: `snake_case` in Rust, `kebab-case` in CLI, `PascalCase` in enum variants. +9. **Program ID is the RISC Zero ImageID** — a `[u32; 8]` derived from the compiled guest binary. +10. **State types need `Serialize` + `Deserialize`** (serde) for storage; instruction enum variants derive these automatically. diff --git a/skills/spel/references/cli-ref.md b/skills/spel/references/cli-ref.md new file mode 100644 index 00000000..517f2ddf --- /dev/null +++ b/skills/spel/references/cli-ref.md @@ -0,0 +1,189 @@ +# CLI Reference + +Condensed cheatsheet for `lez-cli` and `lez-client-gen`. + +--- + +## lez-cli Global Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--idl ` | `-i` | IDL JSON file path (required for most commands) | +| `--program ` | `-p` | Program ELF binary (computes ProgramId) | +| `--program-id ` | | 64-char hex ProgramId (overrides `--program`) | +| `--dry-run` | | Print parsed data without submitting transaction | +| `--bin- ` | | Additional binary; auto-fills `---program-id` | + +--- + +## Commands + +### init — Scaffold New Project + +```bash +lez-cli init +``` + +No `--idl` required. Creates full workspace with Makefile, core crate, guest binary, IDL generator, and CLI wrapper. + +### inspect — Print ProgramId + +```bash +lez-cli inspect [FILE...] +``` + +No `--idl` required. Outputs decimal, hex, and ImageID formats for each binary. + +``` +ProgramId (decimal): 12345,67890,... +ProgramId (hex): 00003039,000109b2,... +ImageID (hex bytes): 393000009b210100... ← use this for --program-id +``` + +### idl — Print IDL + +```bash +lez-cli -i idl +``` + +Pretty-prints the loaded IDL JSON. + +### pda (IDL mode) — Compute PDA from IDL Seeds + +```bash +lez-cli -i [-p | --program-id ] pda [-- ] +``` + +Looks up account in IDL, resolves seeds, prints base58 address. + +```bash +# Const-only seeds +lez-cli -i idl.json --program-id abc...def pda counter + +# With arg seed +lez-cli -i idl.json --program-id abc...def pda multisig_state --create-key 0a1b2c... + +# With account seed +lez-cli -i idl.json --program-id abc...def pda vault --user-account EjR7... + +# List all PDAs +lez-cli -i idl.json pda +``` + +### pda (raw mode) — Compute PDA Without IDL + +```bash +lez-cli --program-id <64-CHAR-HEX> pda [SEED2] ... +``` + +No `--idl` required. Each seed: 64-char hex → 32 raw bytes; otherwise UTF-8 zero-padded to 32 bytes. Multi-seed: `SHA-256(seed1 || seed2 || ...)`. + +```bash +lez-cli --program-id abc...def pda my_state +lez-cli --program-id abc...def pda multisig_vault__ 0a1b2c3d... +``` + +### Instruction Execution + +```bash +lez-cli -i [-p | --program-id ] [-- ] [---account ] +``` + +- Instruction names: `snake_case` → `kebab-case` (`create_proposal` → `create-proposal`) +- PDA accounts: auto-computed, not passed as arguments +- Account IDs: base58 or 64-char hex (with optional `0x` prefix) +- Rest accounts: comma-separated list + +```bash +# Execute instruction +lez-cli -i idl.json -p prog.bin create \ + --create-key 0a1b... --threshold 2 \ + --members "aa...00,bb...00" \ + --creator-account EjR7... + +# Dry run +lez-cli -i idl.json --program-id abc...def --dry-run approve \ + --proposal-id 5 --member-account cc...00 + +# Cross-program binary reference +lez-cli -i treasury-idl.json -p treasury.bin --bin-token token.bin \ + transfer --amount 100 --from-account aa...00 + +# Per-instruction help +lez-cli -i idl.json --help +``` + +--- + +## Type Format Table + +| IDL Type | CLI Format | Example | +|----------|-----------|---------| +| `u8` | Decimal | `255` | +| `u32` | Decimal | `1000000` | +| `u64` | Decimal | `1000000000` | +| `u128` | Decimal | `340282366920938463...` | +| `bool` | `true`/`false`/`1`/`0`/`yes`/`no` | `true` | +| `string` | Plain text | `"hello"` | +| `[u8; N]` | Hex (`2*N` chars) or UTF-8 (≤N chars, zero-padded) | `0a1b2c...` or `my_str` | +| `[u32; 8]` / `program_id` | 8 comma-separated u32 or 64-char hex | `abc123...def` | +| `Vec<[u8; 32]>` | Comma-separated hex strings | `"aa...00,bb...00"` | +| `Vec` | Comma-separated decimal bytes | `1,2,3,4,5` | +| `Vec` | Comma-separated u32 values | `100,200,300` | +| `Option` | `none`/`null` for None; otherwise inner type | `none` or `42` | +| Account IDs | Base58 or 64-char hex (optional `0x`) | `EjR7...` or `0xaa...00` | + +--- + +## lez-client-gen + +Generate typed Rust client + C FFI + C header from IDL: + +```bash +lez-client-gen --idl --out-dir +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--idl ` | Yes | IDL JSON file | +| `--out-dir ` | Yes | Output directory (created if needed) | + +Output files: + +``` +/ +├── _client.rs # typed async Rust client with PDA helpers +├── _ffi.rs # extern "C" functions accepting JSON +└── .h # C header +``` + +Build FFI as shared library: + +```toml +# Cargo.toml +[lib] +name = "my_program_ffi" +crate-type = ["cdylib"] +``` + +```rust +// src/lib.rs +include!("../generated/my_program_ffi.rs"); +``` + +```bash +cargo build --release --lib +# → target/release/libmy_program_ffi.so +``` + +FFI JSON fields (every call): + +| Field | Type | Description | +|-------|------|-------------| +| `wallet_path` | `string` | Path to NSSA wallet directory | +| `sequencer_url` | `string` | Sequencer URL (e.g., `http://127.0.0.1:3040`) | +| `program_id_hex` | `string` | 64-char hex program ID | + +Plus instruction-specific account and argument fields. + +Return format: `{ "success": true, "tx_hash": "..." }` or `{ "success": false, "error": "..." }`. diff --git a/skills/spel/references/gotchas.md b/skills/spel/references/gotchas.md new file mode 100644 index 00000000..e51101f6 --- /dev/null +++ b/skills/spel/references/gotchas.md @@ -0,0 +1,168 @@ +# Gotchas and Common Mistakes + +Hard-won lessons from building SPEL programs. Read this before writing or debugging. + +--- + +## Account Handling + +### Return ALL accounts in post_states + +Every account passed to an instruction must appear in the `post_states` vector, even if unchanged. Forgetting an account causes a runtime error. + +```rust +// WRONG — forgot to return owner +Ok(LezOutput::states_only(vec![ + AccountPostState::new(updated_state), +])) + +// RIGHT — return all accounts +Ok(LezOutput::states_only(vec![ + AccountPostState::new(updated_state), + AccountPostState::new(owner.account.clone()), +])) +``` + +### new_claimed vs new + +- `AccountPostState::new_claimed(account)` — for `init` accounts only (claims a new account) +- `AccountPostState::new(account)` — for existing accounts (updates) + +Using `new()` on an init account or `new_claimed()` on an existing account will fail at runtime. + +### new_claimed_if_default() pattern + +When an account might or might not already exist, use the conditional pattern: + +```rust +// Claims if account is default (uninitialized), updates otherwise +if account.account == Account::default() { + AccountPostState::new_claimed(account) +} else { + AccountPostState::new(account) +} +``` + +### Account ownership rules + +Only the program that owns an account can **decrease** its balance. This is enforced by the NSSA runtime, not by SPEL. Any program can increase a balance. Violating this causes a runtime rejection. + +### init implies mut + +Do not write `#[account(init, mut)]` — `init` already implies `mut`. Just use `#[account(init)]`. + +## PDA Derivation + +### PDA accounts are never passed as CLI arguments + +The CLI auto-computes PDA addresses from seeds + program ID. If you see an "unknown argument" error for a PDA account, something is wrong with the IDL or seed definition. + +### Seeds are zero-padded to 32 bytes + +String seeds (`literal("x")`) are UTF-8 encoded and zero-padded to exactly 32 bytes. Strings longer than 32 bytes will be truncated. Keep seed strings short. + +### Multi-seed derivation uses SHA-256 + +When using multiple seeds (`pda = [literal("a"), account("b")]`), they are combined via `SHA-256(seed1_32 || seed2_32 || ...)` into a single 32-byte seed. This is not simple concatenation. + +### Program ID is [u32; 8], not [u8; 32] + +The Program ID is the RISC Zero ImageID — an array of 8 u32 values. When converting from hex, use `from_le_bytes` for each u32 chunk (little-endian byte order). The 64-char hex ImageID from `lez-cli inspect` is the little-endian byte representation. + +### bytemuck for raw PDA mode + +When computing PDAs outside the framework (raw mode), use `bytemuck` for safe casting between `[u32; 8]` and byte slices. Do not manually reinterpret memory. + +## IDL Generation + +### Never edit IDL JSON by hand + +The IDL is generated by the `generate_idl!` macro. Any manual edits will be overwritten on the next `make idl`. If the IDL is wrong, fix the program source and regenerate. + +### generate_idl! path is relative to CARGO_MANIFEST_DIR + +The path in `generate_idl!("../methods/guest/src/bin/my_program.rs")` is relative to the crate containing the macro invocation (typically `examples/`), not the workspace root. + +### generate_idl! must find exactly one #[lez_program] module + +The source file passed to `generate_idl!` must contain exactly one `#[lez_program]` module. Multiple modules or zero modules will cause a compile error. + +## Macro and Code Generation + +### Account parameters must come before instruction arguments + +In `#[instruction]` function signatures, all `AccountWithMetadata` / `Vec` parameters must come before any instruction argument parameters. The macro relies on this ordering. + +```rust +// WRONG — argument before account +#[instruction] +pub fn bad(amount: u64, #[account(signer)] user: AccountWithMetadata) -> LezResult { ... } + +// RIGHT — accounts first, then arguments +#[instruction] +pub fn good(#[account(signer)] user: AccountWithMetadata, amount: u64) -> LezResult { ... } +``` + +### Vec must be the last account parameter + +Variable-length (rest) accounts consume all remaining accounts in the pre_states array. There can be at most one, and it must be the last account parameter. + +### Instruction functions cannot have self + +All `#[instruction]` functions must be free functions (no `self`, `&self`, or `&mut self`). + +### External instruction enum must derive BorshSerialize + BorshDeserialize + +When using `#[lez_program(instruction = "my_core::Instruction")]`, the external enum must derive both `Serialize`/`Deserialize` (serde) AND `BorshSerialize`/`BorshDeserialize` for correct zkVM serialization. + +## CLI Usage + +### Empty string arguments are dropped by logoscore + +If you pass an empty string `""` as an instruction argument, logoscore may silently drop it. Always pass non-empty strings or use `Option` with explicit `none`. + +### Instruction names transform: snake_case → kebab-case + +`create_proposal` in Rust becomes `create-proposal` in the CLI. Account flags use `--{name}-account` suffix. + +### --program-id expects 64-char hex (ImageID format) + +The `--program-id` flag expects the 64-character hex string from `lez-cli inspect` (ImageID hex bytes), not the decimal or comma-separated format. + +### Account IDs accept both base58 and hex + +When passing account IDs, both base58 strings and 64-char hex strings (with optional `0x` prefix) are accepted. + +## Build and Deploy + +### Rust nightly toolchain required + +SPEL programs compile for the RISC Zero zkVM, which requires the nightly Rust toolchain. Ensure `rustup install nightly` has been run. + +### Binary path for deployed programs + +The compiled binary lives at `methods/guest/target/riscv32im-risc0-zkvm-elf/docker/{name}.bin`. This path is used with `-p` flag or in the Makefile. + +### NSSA_WALLET_HOME_DIR environment variable + +The CLI reads the wallet from the `NSSA_WALLET_HOME_DIR` environment variable. Ensure it is set before running transactions. + +## Testing + +### main() is cfg-gated for tests + +The generated `main()` is wrapped in `#[cfg(not(test))]`, so instruction handler functions are directly callable in host-side unit tests without zkVM. + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_handler() { + let acc = AccountWithMetadata { /* ... */ }; + let result = my_program::my_instruction(acc); + assert!(result.is_ok()); + } +} +``` diff --git a/skills/spel/references/quickstart.md b/skills/spel/references/quickstart.md new file mode 100644 index 00000000..40a5e00e --- /dev/null +++ b/skills/spel/references/quickstart.md @@ -0,0 +1,218 @@ +# Quickstart: Scaffold to Deploy + +Full workflow for creating, building, deploying, and interacting with a LEZ program using SPEL. + +--- + +## 1. Scaffold + +```bash +lez-cli init my-program +cd my-program +``` + +Generated structure: + +``` +my-program/ +├── Cargo.toml # workspace +├── Makefile # build, idl, cli, deploy, inspect, setup targets +├── my_program_core/src/lib.rs # shared types +├── methods/guest/src/bin/my_program.rs # on-chain guest binary +├── examples/src/bin/ +│ ├── generate_idl.rs # IDL generator (one-liner macro) +│ └── my_program_cli.rs # CLI wrapper (three lines) +└── methods/build.rs +``` + +## 2. Define State + +Edit `my_program_core/src/lib.rs`: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MyState { + pub value: u64, + pub owner: [u8; 32], +} +``` + +State structs live in the `_core` crate so they can be shared between on-chain and off-chain code. + +## 3. Write Instructions + +Edit `methods/guest/src/bin/my_program.rs`: + +```rust +#![no_main] + +use nssa_core::account::AccountWithMetadata; +use nssa_core::program::AccountPostState; +use lez_framework::prelude::*; + +risc0_zkvm::guest::entry!(main); + +#[lez_program] +mod my_program { + #[allow(unused_imports)] + use super::*; + + #[instruction] + pub fn initialize( + #[account(init, pda = literal("state"))] + state: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + ) -> LezResult { + let data = borsh::to_vec(&my_program_core::MyState { + value: 0, + owner: *owner.account_id.value(), + }).map_err(|e| LezError::SerializationError { message: e.to_string() })?; + + let mut new_account = state.account.clone(); + new_account.data = data.try_into().unwrap(); + + Ok(LezOutput::states_only(vec![ + AccountPostState::new_claimed(new_account), + AccountPostState::new(owner.account.clone()), + ])) + } + + #[instruction] + pub fn update( + #[account(mut, pda = literal("state"))] + state: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + new_value: u64, + ) -> LezResult { + let mut current: my_program_core::MyState = + borsh::from_slice(&state.account.data) + .map_err(|e| LezError::DeserializationError { + account_index: 0, message: e.to_string(), + })?; + + if *owner.account_id.value() != current.owner { + return Err(LezError::Unauthorized { + message: "Only the owner can update".to_string(), + }); + } + + current.value = new_value; + let data = borsh::to_vec(¤t) + .map_err(|e| LezError::SerializationError { message: e.to_string() })?; + let mut updated = state.account.clone(); + updated.data = data.try_into().unwrap(); + + Ok(LezOutput::states_only(vec![ + AccountPostState::new(updated), + AccountPostState::new(owner.account.clone()), + ])) + } +} +``` + +## 4. Set Up IDL Generator + +`examples/src/bin/generate_idl.rs` (scaffold creates this): + +```rust +lez_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); +``` + +Path is relative to `CARGO_MANIFEST_DIR` (the `examples/` crate). + +## 5. Set Up CLI Wrapper + +`examples/src/bin/my_program_cli.rs` (scaffold creates this): + +```rust +#[tokio::main] +async fn main() { + lez_cli::run().await; +} +``` + +## 6. Build + +```bash +make build # compiles RISC Zero zkVM guest binary +``` + +## 7. Generate IDL + +```bash +make idl # runs cargo run --bin generate_idl > my-program-idl.json +``` + +## 8. Deploy + +```bash +make setup # create signer account in wallet +make deploy # deploy binary to sequencer +make inspect # print ProgramId (decimal, hex, ImageID) +``` + +Save the 64-char hex ImageID from `make inspect` output. + +## 9. Call Instructions + +```bash +# See available commands +make cli ARGS="--help" + +# Initialize (PDA accounts auto-computed, not passed as args) +make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin \ + initialize --owner-account " + +# Update with argument +make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin \ + update --new-value 42 --owner-account " + +# Use --program-id to skip binary loading +make cli ARGS="--program-id <64-CHAR-HEX> \ + update --new-value 100 --owner-account " + +# Dry run (no submission) +make cli ARGS="--dry-run -p methods/guest/target/...bin update --new-value 5 --owner-account " + +# Compute PDA manually +make cli ARGS="--program-id <64-CHAR-HEX> pda state" +``` + +## 10. Generate Client Code (optional) + +```bash +lez-client-gen --idl my-program-idl.json --out-dir generated/ +``` + +Produces: +- `my_program_client.rs` — typed async Rust client +- `my_program_ffi.rs` — C FFI (`extern "C"` functions accepting JSON) +- `my_program.h` — C header + +Build as shared library: + +```bash +cargo build --release --lib +# Produces libmy_program.so / libmy_program.dylib +``` + +## 11. Register in SPELbook (optional) + +Register the deployed program in SPELbook to make it discoverable. (Process TBD.) + +## Makefile Targets Reference + +| Target | Description | +|--------|-------------| +| `make build` | Compile guest binary for RISC Zero zkVM | +| `make idl` | Generate IDL JSON from program source | +| `make cli ARGS="..."` | Run the IDL-driven CLI with given arguments | +| `make deploy` | Deploy program binary to sequencer | +| `make setup` | Create signer account in wallet | +| `make inspect` | Print ProgramId for the compiled binary | +| `make status` | Check deployment status | +| `make clean` | Clean build artifacts | From 00a0bb766f722a59cf27d7e7eaa4b2d0a76c5feb Mon Sep 17 00:00:00 2001 From: Jimmy Claw Date: Mon, 16 Mar 2026 07:45:49 +0000 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20update=20README=20=E2=80=94=20SPEL?= =?UTF-8?q?=20naming,=20doc=20links,=20fix=20imports,=20dual=20license?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 46 +++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ac03a953..22857f18 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ -# lez-framework +# SPEL — Smart Program Execution Layer -[![CI](https://github.com/jimmy-claw/lez-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/jimmy-claw/lez-framework/actions/workflows/ci.yml) +[![CI](https://github.com/logos-co/spel/actions/workflows/ci.yml/badge.svg)](https://github.com/logos-co/spel/actions/workflows/ci.yml) -Developer framework for building LEZ programs — inspired by [Anchor](https://www.anchor-lang.com/) for Solana. +Developer framework for building [LEZ](https://github.com/logos-blockchain/lssa) programs — inspired by [Anchor](https://www.anchor-lang.com/) for Solana. Write your program logic with proc macros. Get IDL generation, a full CLI with TX submission, and project scaffolding for free. +## Documentation + +- **[Tutorial: Build Your First LEZ Program](docs/tutorial.md)** — step-by-step guide from zero to deployed program +- **[Reference Docs](docs/reference/)** — macros, types, CLI, IDL, and client generation +- **[Multi-Seed PDA Guide](docs/multi-seed-pda.md)** — advanced PDA derivation patterns + ## Quick Start ### Scaffold a new project @@ -16,7 +22,7 @@ lez-cli init my-program cd my-program ``` -This generates a complete project: +This generates a complete project with a `Cargo.lock` for reproducible builds: ``` my-program/ @@ -27,6 +33,7 @@ my-program/ │ └── src/lib.rs ├── methods/ │ └── guest/ # RISC Zero guest (runs on-chain) +│ ├── Cargo.lock # Pinned deps for reproducible Docker builds │ └── src/bin/my_program.rs └── examples/ └── src/bin/ @@ -49,8 +56,6 @@ make cli ARGS="-p initialize --owner-account " ```rust #![no_main] -use nssa_core::account::AccountWithMetadata; -use nssa_core::program::AccountPostState; use lez_framework::prelude::*; risc0_zkvm::guest::entry!(main); @@ -63,7 +68,7 @@ mod my_program { #[instruction] pub fn initialize( #[account(init, pda = literal("state"))] - state: AccountWithMetadata, + mut state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, ) -> LezResult { @@ -77,7 +82,7 @@ mod my_program { #[instruction] pub fn transfer( #[account(mut, pda = literal("state"))] - state: AccountWithMetadata, + mut state: AccountWithMetadata, recipient: AccountWithMetadata, #[account(signer)] sender: AccountWithMetadata, @@ -93,6 +98,8 @@ mod my_program { } ``` +> **Note:** Import everything from `lez_framework::prelude::*` — this provides `AccountWithMetadata`, `AccountPostState`, `LezOutput`, `LezResult`, `LezError`, `BorshSerialize`, `BorshDeserialize`, and more. Do not import from `nssa_core` directly to avoid version conflicts. + ### Account Attributes | Attribute | Description | @@ -143,7 +150,7 @@ This provides: - risc0-compatible serialization - Transaction building and submission with wallet integration - `--dry-run` mode for testing -- `inspect` subcommand to extract ProgramId from binaries +- `inspect` subcommand to extract ProgramId from binaries and decode account data ### IDL Generation @@ -159,15 +166,15 @@ It reads the `#[lez_program]` annotations at compile time and generates a comple The generated IDL is a superset of the lssa-lang IDL spec. In addition to our core fields, each instruction includes: -- **discriminator** -- SHA256 of global:name, first 8 bytes, matching lssa-lang convention -- **execution** -- public/private_owned flags (default: public execution) -- **variant** -- PascalCase variant name +- **discriminator** — SHA256 of global:name, first 8 bytes, matching lssa-lang convention +- **execution** — public/private_owned flags (default: public execution) +- **variant** — PascalCase variant name Each account field includes: -- **visibility** -- list of visibility tags (default: public) +- **visibility** — list of visibility tags (default: public) -These fields are optional and backward-compatible -- existing IDL consumers that do not know about them will simply ignore them. +These fields are optional and backward-compatible — existing IDL consumers that do not know about them will simply ignore them. ## CLI Usage @@ -178,6 +185,9 @@ lez-cli init my-program # Inspect program binaries (no --idl needed) lez-cli inspect program.bin +# Inspect with raw data (offline, no sequencer needed) +lez-cli inspect --data --idl program-idl.json + # Show available commands lez-cli --idl program-idl.json --help @@ -190,10 +200,12 @@ lez-cli --idl program-idl.json -p program.bin \ create-vault --token-name "MYTKN" --initial-supply 1000000 # Use --program-id instead of binary (skips loading the file) -lez-cli --idl program-idl.json --program-id <64-char-hex> create-vault --token-name "MYTKN" --initial-supply 1000000 +lez-cli --idl program-idl.json --program-id <64-char-hex> \ + create-vault --token-name "MYTKN" --initial-supply 1000000 # Compute a PDA from the IDL -lez-cli --idl program-idl.json --program-id <64-char-hex> pda vault --create-key my-multisig +lez-cli --idl program-idl.json --program-id <64-char-hex> \ + pda vault --create-key my-multisig # Auto-fill program IDs from binaries lez-cli --idl program-idl.json -p treasury.bin --bin-token token.bin \ @@ -229,4 +241,4 @@ lez-cli --idl program-idl.json create-vault --help ## License -MIT +Dual-licensed under [MIT](LICENSE-MIT) and [Apache-2.0](LICENSE-APACHE-v2). From 239f9eb7b60c0338f8c5523050f2b245e0ead664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Fri, 24 Apr 2026 10:19:46 +0200 Subject: [PATCH 6/7] docs: refresh tutorial/reference/skill for v0.2.0 CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates PR #63's docs to match post-rename, post-arg-parsing-refactor `spel` CLI. Jimmy is unavailable — taking over the branch. - Rename lez-cli → spel, lez_cli → spel_cli, lez-client-gen → spel-client-gen, lez-framework[-core|-macros] → spel-framework[-core|-macros], and type names LezOutput/LezError/LezResult/LezIdl → Spel* across all added docs. Keeps LEZ (the zone name), lez-multisig (the external repo), and the macro names #[lez_program]/#[instruction]/#[account] as-is. - Document spel.toml config discovery with [program] and [programs.] forms in cli.md, tutorial.md (Step 8), skill cli-ref.md, quickstart.md, and SKILL.md. Note that `init` scaffolds one by default. - Document --dry-run[=text|json] — full tx resolution output, JSON mode suppresses preamble, u128/nonce precision note, example output. - Add `--` separator explanation and update every multi-flag example. - --program now accepts NAME|HEX|FILE (from spel.toml name, raw program ID, or binary path); mark --program-id deprecated. - Document PDA seed-input display (`seeds: [program_id, "state", ...]`) in the pda section and dry-run text output. - Resolve fryorcraken's scaffold/init ambiguity (cli.md:37) with an inline note that "scaffolding" and `init` are the same operation. - Fix `cargo install --path spel` → `cargo install --path spel-cli`. Co-Authored-By: Jimmy Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/reference/README.md | 6 +- docs/reference/cli.md | 247 ++++++++++++++++++++++----- docs/reference/client-gen.md | 14 +- docs/reference/idl.md | 6 +- docs/reference/macros.md | 42 ++--- docs/reference/types.md | 34 ++-- docs/tutorial.md | 164 ++++++++++++------ skills/spel/SKILL.md | 16 +- skills/spel/references/cli-ref.md | 109 ++++++++---- skills/spel/references/gotchas.md | 33 +++- skills/spel/references/quickstart.md | 58 ++++--- 11 files changed, 507 insertions(+), 222 deletions(-) diff --git a/docs/reference/README.md b/docs/reference/README.md index e1fcfe0f..4eb742fa 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -9,7 +9,7 @@ For a guided walkthrough, see the [Tutorial](../tutorial.md). ## Reference Pages - [**Macros**](macros.md) — `#[lez_program]`, `#[instruction]`, `generate_idl!`, and generated validation functions -- [**Types**](types.md) — Framework types: `LezOutput`, `LezError`, `AccountConstraint`, `ChainedCall`, `PdaSeed`, and the prelude -- [**CLI**](cli.md) — All `lez-cli` commands (`init`, `inspect`, `idl`, `pda`, instruction execution) with flags, examples, and type format table +- [**Types**](types.md) — Framework types: `SpelOutput`, `SpelError`, `AccountConstraint`, `ChainedCall`, `PdaSeed`, and the prelude +- [**CLI**](cli.md) — All `spel` commands (`init`, `inspect`, `idl`, `pda`, instruction execution) with flags, examples, and type format table - [**IDL Format**](idl.md) — IDL JSON schema, instruction/account/arg definitions, discriminators, and lssa-lang compatibility fields -- [**Client Code Generation**](client-gen.md) — `lez-client-gen` CLI, library API, generated Rust client, C FFI wrappers, C header, and C++/Qt integration example +- [**Client Code Generation**](client-gen.md) — `spel-client-gen` CLI, library API, generated Rust client, C FFI wrappers, C header, and C++/Qt integration example diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3d64f143..cb84f740 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,6 +1,6 @@ # CLI -The `lez-cli` crate provides a generic, IDL-driven command-line interface for any LEZ program. Programs get a complete CLI by writing a three-line wrapper. +The `spel-cli` crate provides a generic, IDL-driven command-line interface for any SPEL program. Programs get a complete CLI by writing a three-line wrapper — the binary is named `spel`. For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). @@ -11,30 +11,79 @@ For a guided walkthrough, see the [Tutorial](../tutorial.md). For other referenc ```rust #[tokio::main] async fn main() { - lez_cli::run().await; + spel_cli::run().await; } ``` --- +## Invocation Syntax + +``` +spel [ARGS] (with spel.toml) +spel [OPTIONS] -- [ARGS] (without spel.toml) +``` + +The `--` separator is required whenever you pass global `OPTIONS` (like `--idl` or `--program`) together with a command that also takes its own `--`-flags. Without it, the first `--foo` after the command name would be parsed as a global flag, not an instruction argument. + +When a `spel.toml` is present in the current directory (or any ancestor), `--idl` and `--program` are resolved from it automatically — the `--` separator is not needed in that case. + +--- + +## Configuration: `spel.toml` + +A `spel.toml` file in your project root lets you drop `--idl`/`--program` flags and makes multi-program projects ergonomic. `spel` walks up from the current directory until it finds one. + +**Single-program projects:** + +```toml +[program] +idl = "my-program-idl.json" +binary = "target/riscv32im-risc0-zkvm-elf/docker/my_program.bin" +``` + +**Multi-program projects:** + +```toml +[programs.game] +idl = "game-idl.json" +binary = "target/game.bin" + +[programs.nft] +idl = "nft-idl.json" +binary = "target/nft.bin" +``` + +With a multi-program config, pick one with `--program `: + +```bash +spel --program game create --name "first-game" +``` + +When only one `[programs.]` entry exists it is auto-selected; with multiple entries and no `--program `, the CLI errors out listing the available names. + +`[program]` and `[programs]` are mutually exclusive. Paths inside the config are resolved relative to the `spel.toml` file, so invocations from subdirectories work. + +--- + ## Global Options | Option | Short | Description | |--------|-------|-------------| -| `--idl ` | `-i` | Path to the IDL JSON file. Required for most commands. | -| `--program ` | `-p` | Path to the program ELF binary. Used to compute ProgramId and for deployment. Defaults to `program.bin`. | -| `--program-id ` | | 64-character hex string of the program ID. Overrides `--program` for ProgramId resolution (faster, no binary loading). | -| `--dry-run` | | Print parsed/serialized data without submitting the transaction. | +| `--idl ` | `-i` | Path to the IDL JSON file. Required if not set in `spel.toml`. | +| `--program ` | `-p` | Accepts one of three things: (a) a program **name** from `spel.toml` → resolves both IDL and binary; (b) a 64-char **hex program ID** → skips binary loading; (c) a **file path** to the program ELF binary. | +| `--dry-run[=text\|json]` | | Resolve everything (PDAs, accounts, signer nonces, serialized data) and print without submitting. `--dry-run` and `--dry-run=text` produce a human-readable report; `--dry-run=json` emits a machine-readable document on stdout. | | `--bin- ` | | Additional program binary. Auto-fills `---program-id` from the binary's image ID. Useful for cross-program references. | +| `--program-id ` | | **Deprecated** — prefer `--program `. Still accepted. | --- ## `init` -Scaffold a new LEZ project. +Scaffold a new SPEL project. This is the command that creates everything described below — "scaffolding" and `init` refer to the same operation. ```bash -lez-cli init +spel init [--lez-tag ] [--spel-tag ] [--lez-rev ] [--spel-rev ] ``` **Does not require `--idl`.** @@ -45,13 +94,23 @@ Creates a complete project structure with: - `methods/guest/` with a skeleton `#[lez_program]` guest binary - `examples/` with `generate_idl.rs` and `{name}_cli.rs` - `Makefile` with `build`, `idl`, `cli`, `deploy`, `inspect`, `setup`, `status`, `clean` targets +- `spel.toml` (so you can run `spel` without `--idl`/`--program`) - `README.md` with quick start guide - `.gitignore` +**Options:** + +| Flag | Purpose | +|------|---------| +| `--lez-tag ` | LEZ version tag to pin (e.g. `v0.2.0-rc1`). | +| `--spel-tag ` | SPEL version tag to pin. | +| `--lez-rev ` | LEZ git revision (alternative to `--lez-tag`). | +| `--spel-rev ` | SPEL git revision (alternative to `--spel-tag`). | + **Example:** ```bash -lez-cli init my-token +spel init my-token cd my-token # Edit methods/guest/src/bin/my_token.rs with your program logic make idl @@ -62,10 +121,12 @@ make cli ARGS="--help" ## `inspect` -Print the ProgramId for one or more ELF binaries. +Two modes — the one you get depends on whether `--idl`/`--type` are set. + +### Mode 1: Print ProgramId for ELF binaries ```bash -lez-cli inspect [FILE...] +spel inspect [FILE...] ``` **Does not require `--idl`.** @@ -79,15 +140,27 @@ lez-cli inspect [FILE...] ImageID (hex bytes): 393000009b210100... ``` -The three formats: - **Decimal**: comma-separated `[u32; 8]` values - **Hex**: comma-separated hex `[u32; 8]` values -- **ImageID hex bytes**: 64-character hex string (little-endian byte representation) +- **ImageID hex bytes**: 64-character hex string (little-endian byte representation). This is the value to pass to `--program `. **Example:** ```bash -lez-cli inspect methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin +spel inspect methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin +``` + +### Mode 2: Decode account data + +```bash +spel inspect --idl --type [--data ] +``` + +Fetches the account (or decodes supplied borsh-hex bytes) and renders the data as JSON using the IDL-declared type. + +```bash +spel inspect --idl my_program-idl.json --type VaultState +spel inspect --idl my_program-idl.json --type VaultState --data ``` --- @@ -97,48 +170,64 @@ lez-cli inspect methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program. Print the loaded IDL as pretty-printed JSON. ```bash -lez-cli --idl idl +spel --idl idl ``` -**Example:** +With `spel.toml`: ```bash -lez-cli -i my_program-idl.json idl +spel idl ``` --- +## `generate-idl` + +Generate IDL JSON directly from a program source file. Useful if you don't want a runtime `examples/generate_idl.rs` binary. + +```bash +spel generate-idl +``` + +**Does not require `--idl`.** + +- `` may be a single source file, or a project directory to search for `#[lez_program]` entry points. +- Single match → IDL JSON printed to stdout. +- Multiple matches → one `-idl.json` file written per program. + +--- + ## `pda` (IDL mode) Compute a PDA address from the IDL-defined seeds. ```bash -lez-cli --idl [-p | --program-id ] pda [-- ...] +spel --idl --program pda [-- ...] ``` Looks up the named account across all instructions in the IDL, finds its PDA seed definition, resolves all seeds, and prints the base58 AccountId. **Seed resolution:** - `const` seeds: resolved from the IDL definition -- `arg` seeds: must be provided via `-- ` +- `arg` seeds: must be provided via `-- ` (parsed through the IDL type of the owning instruction's argument) - `account` seeds: must be provided via `---account ` **ProgramId resolution** (in priority order): -1. `--program-id ` flag -2. Load from `--program ` binary +1. `--program <64-char-hex>` +2. `--program ` → resolved binary loaded +3. `--program ` → binary loaded **Example:** ```bash # Simple PDA with only const seeds -lez-cli -i my_program-idl.json --program-id abc123...def pda counter +spel --idl my_program-idl.json --program abc123...def pda counter -# PDA with arg seed -lez-cli -i multisig-idl.json --program-id abc123...def pda multisig_state \ - --create-key 0a1b2c... +# PDA with arg seed (with spel.toml set up) +spel pda multisig_state --create-key 0a1b2c... # List available PDAs -lez-cli -i my_program-idl.json pda +spel --idl my_program-idl.json pda ``` **If no account name is given**, prints all PDA accounts found in the IDL. @@ -150,7 +239,7 @@ lez-cli -i my_program-idl.json pda Compute an arbitrary PDA from a program ID and raw seeds — no IDL required. ```bash -lez-cli --program-id <64-CHAR-HEX> pda [SEED2] ... +spel --program <64-CHAR-HEX> pda [SEED2] ... ``` **Does not require `--idl`.** @@ -167,10 +256,10 @@ Each seed can be: ```bash # Single seed -lez-cli --program-id abc123...def pda my_state_name +spel --program abc123...def pda my_state_name # Multiple seeds -lez-cli --program-id abc123...def pda multisig_vault__ 0a1b2c3d... +spel --program abc123...def pda multisig_vault__ 0a1b2c3d... ``` --- @@ -180,7 +269,13 @@ lez-cli --program-id abc123...def pda multisig_vault__ 0a1b2c3d... Execute any instruction defined in the IDL. The CLI auto-generates subcommands from the IDL. ```bash -lez-cli --idl [-p | --program-id ] [-- ...] [---account ...] +spel --idl --program -- [-- ...] [---account ...] +``` + +With `spel.toml`: + +```bash +spel [-- ...] [---account ...] ``` Instruction names are converted from `snake_case` to `kebab-case` in CLI commands (e.g., `create_proposal` → `create-proposal`). @@ -197,7 +292,7 @@ Instruction names are converted from `snake_case` to `kebab-case` in CLI command 1. Parse and validate all arguments 2. Auto-fill program IDs from `--bin-*` flags 3. Serialize instruction data in risc0 serde format -4. Resolve PDA accounts from seeds +4. Resolve PDA accounts from seeds (printing each seed input it used) 5. Initialize wallet from `NSSA_WALLET_HOME_DIR` environment variable 6. Fetch nonces for signer accounts 7. Build, sign, and submit the transaction @@ -206,35 +301,99 @@ Instruction names are converted from `snake_case` to `kebab-case` in CLI command **Per-instruction help:** ```bash -lez-cli --idl --help +spel --help ``` Shows accounts (with flags like `[mut, signer, init]`), PDA status, and argument types. -**Example:** +**Example (no spel.toml):** ```bash # Execute a create instruction -lez-cli -i multisig-idl.json -p multisig.bin create \ +spel --idl multisig-idl.json --program multisig.bin -- create \ --create-key 0a1b2c3d4e5f... \ --threshold 2 \ --members "aabb...00,ccdd...00" \ --creator-account EjR7...base58 -# Dry run (no submission) -lez-cli -i multisig-idl.json --program-id abc123...def --dry-run approve \ - --proposal-id 5 \ - --proposal-account aabb...00 \ - --member-account ccdd...00 - # Auto-fill cross-program reference -lez-cli -i treasury-idl.json -p treasury.bin \ - --bin-token token.bin \ +spel --idl treasury-idl.json --program treasury.bin \ + --bin-token token.bin -- \ transfer --amount 100 \ --from-account aabb...00 \ --to-account ccdd...00 ``` +**Example (with spel.toml in the project root):** + +```bash +spel create \ + --create-key 0a1b2c3d4e5f... \ + --threshold 2 \ + --members "aabb...00,ccdd...00" \ + --creator-account EjR7...base58 +``` + +--- + +## Dry Run + +`--dry-run` resolves the entire transaction — PDAs, non-PDA account IDs, signer nonces, serialized instruction bytes — and prints it without submitting. Useful for CI golden tests, for previewing a TX before signing, and for scripting. + +```bash +spel --dry-run --arg1 value1 # text, human-readable (default) +spel --dry-run=text --arg1 value1 # same as above, explicit +spel --dry-run=json --arg1 value1 # machine-readable JSON +``` + +**Text output:** + +``` +=== Dry Run === +Program ID: abc123...def +Instruction: transfer + +Accounts: + PDA vault → 4Lp3gkH... [writable] + seeds: [program_id, "state"] + recipient → 0xaabb...00 + sender → 0xccdd...00 [signer] + +Arguments: + --amount 1000 + +Instruction data: 0x01000000e803000000000000... + +Signers: + sender: nonce=42 +================ +Dry run complete — not submitted. +``` + +**JSON output (shape):** + +```json +{ + "program_id": "abc123...def", + "instruction": "transfer", + "accounts": [ + { + "name": "vault", "id": "4Lp3gkH...", "flags": ["writable"], + "is_pda": true, + "seeds": [{"kind": "const", "value": "state"}] + }, + { "name": "sender", "id": "0xccdd...00", "flags": ["signer"] } + ], + "arguments": { "amount": 1000 }, + "instruction_data": "01000000e803000000000000...", + "signers": { "sender": {"nonce": "42"} } +} +``` + +Numeric values that exceed JSON's 53-bit integer precision (`u128` args and nonces) are emitted as decimal strings to avoid silent truncation. + +In JSON mode, all human-readable preamble is suppressed — only the JSON document goes to stdout — so it's safe to pipe through `jq`. + --- ## Type Format Table @@ -264,9 +423,9 @@ How to pass values for each IDL type on the command line: --- -## Serialization (lez-cli internals) +## Serialization (spel-cli internals) -The CLI serializes instruction data in **risc0 serde format** (`Vec`) for submission to the zkVM guest. The format is: +The CLI serializes instruction data using `risc0_zkvm::serde::to_vec` (risc0 serde format, `Vec`) for submission to the zkVM guest. The format is: ``` [variant_index: u32, field1_words..., field2_words..., ...] diff --git a/docs/reference/client-gen.md b/docs/reference/client-gen.md index 01b76f1b..e0c686a7 100644 --- a/docs/reference/client-gen.md +++ b/docs/reference/client-gen.md @@ -1,15 +1,15 @@ # Client Code Generation -`lez-client-gen` generates typed Rust client code and C FFI wrappers from LEZ program IDL JSON. Useful for integrating LEZ programs into applications (e.g., C++/Qt desktop apps). +`spel-client-gen` generates typed Rust client code and C FFI wrappers from LEZ program IDL JSON. Useful for integrating LEZ programs into applications (e.g., C++/Qt desktop apps). For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). --- -## lez-client-gen CLI Usage +## spel-client-gen CLI Usage ```bash -lez-client-gen --idl --out-dir +spel-client-gen --idl --out-dir ``` | Option | Required | Description | @@ -30,7 +30,7 @@ lez-client-gen --idl --out-dir **Example:** ```bash -lez-client-gen --idl multisig-idl.json --out-dir generated/ +spel-client-gen --idl multisig-idl.json --out-dir generated/ # Output: # Generated: @@ -42,14 +42,14 @@ lez-client-gen --idl multisig-idl.json --out-dir generated/ ## Library API ```rust -use lez_client_gen::{generate_from_idl_json, generate_from_idl, CodegenOutput}; +use spel_client_gen::{generate_from_idl_json, generate_from_idl, CodegenOutput}; // From JSON string let json = std::fs::read_to_string("idl.json")?; let output: CodegenOutput = generate_from_idl_json(&json)?; // From parsed IDL -let idl: LezIdl = serde_json::from_str(&json)?; +let idl: SpelIdl = serde_json::from_str(&json)?; let output: CodegenOutput = generate_from_idl(&idl)?; // Write output files @@ -188,7 +188,7 @@ char* my_multisig_version(void); 1. **Generate the FFI:** ```bash -lez-client-gen --idl my_program-idl.json --out-dir ffi/ +spel-client-gen --idl my_program-idl.json --out-dir ffi/ ``` 2. **Build as a shared library** by adding to your `Cargo.toml`: diff --git a/docs/reference/idl.md b/docs/reference/idl.md index 4f97eeec..12f8d237 100644 --- a/docs/reference/idl.md +++ b/docs/reference/idl.md @@ -245,7 +245,7 @@ Error definitions: } ``` -Currently generated as an empty array by the macro. The built-in `LezError` variants have fixed codes (see [Types — LezError](types.md#lezerror)). +Currently generated as an empty array by the macro. The built-in `SpelError` variants have fixed codes (see [Types — SpelError](types.md#lezerror)). --- @@ -264,7 +264,7 @@ This matches the lssa-lang convention. The discriminator is computed at macro ex SHA-256("global:create")[0..8] = [72, 137, 94, 219, 188, 57, 3, 12] ``` -The `compute_discriminator()` function in `lez-framework-core/src/idl.rs` and `lez-framework-macros/src/lib.rs` implements this. +The `compute_discriminator()` function in `spel-framework-core/src/idl.rs` and `spel-framework-macros/src/lib.rs` implements this. --- @@ -296,7 +296,7 @@ When `#[lez_program(instruction = "my_crate::Instruction")]` is used, the IDL in } ``` -This tells `lez-client-gen` to import and use the external type in generated FFI code (instead of generating a local `#[derive(Serialize, Deserialize)]` enum), ensuring correct serialization for the zkVM guest. +This tells `spel-client-gen` to import and use the external type in generated FFI code (instead of generating a local `#[derive(Serialize, Deserialize)]` enum), ensuring correct serialization for the zkVM guest. --- diff --git a/docs/reference/macros.md b/docs/reference/macros.md index 747e174f..00a29428 100644 --- a/docs/reference/macros.md +++ b/docs/reference/macros.md @@ -8,7 +8,7 @@ For a guided walkthrough, see the [Tutorial](../tutorial.md). For other referenc ## `#[lez_program]` -**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) +**Crate:** `spel-framework-macros` (re-exported by `spel-framework`) Attribute macro applied to a module. Transforms a module of `#[instruction]` functions into a complete LEZ guest binary with dispatch, validation, and IDL generation. @@ -18,7 +18,7 @@ Attribute macro applied to a module. Transforms a module of `#[instruction]` fun #[lez_program] mod my_program { #[instruction] - pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } + pub fn my_instruction(/* ... */) -> SpelResult { /* ... */ } } ``` @@ -28,7 +28,7 @@ With external instruction enum: #[lez_program(instruction = "my_crate::Instruction")] mod my_program { #[instruction] - pub fn my_instruction(/* ... */) -> LezResult { /* ... */ } + pub fn my_instruction(/* ... */) -> SpelResult { /* ... */ } } ``` @@ -44,11 +44,11 @@ mod my_program { 2. **`fn main()`** — the zkVM guest entry point (gated by `#[cfg(not(test))]`). Reads `ProgramInput` from the host, dispatches to the correct handler via `match`, and writes outputs via `write_nssa_outputs_with_chained_call`. Account destructuring from `pre_states` is generated per-instruction. -3. **Validation functions** — one `__validate_{fn_name}()` function per instruction that has `signer` or `init` constraints. These run before the handler and return `LezError` on failure. +3. **Validation functions** — one `__validate_{fn_name}()` function per instruction that has `signer` or `init` constraints. These run before the handler and return `SpelError` on failure. 4. **`PROGRAM_IDL_JSON`** — a `pub const &str` containing the complete IDL as JSON. Available at compile time in any build target. -5. **`__program_idl()`** — a function returning a constructed `LezIdl` struct (with discriminators, execution metadata, etc.). +5. **`__program_idl()`** — a function returning a constructed `SpelIdl` struct (with discriminators, execution metadata, etc.). ### Constraints @@ -60,7 +60,7 @@ mod my_program { ### Example ```rust -use lez_framework::prelude::*; +use spel_framework::prelude::*; use nssa_core::account::AccountWithMetadata; use nssa_core::program::AccountPostState; @@ -75,9 +75,9 @@ mod treasury { #[account(signer)] depositor: AccountWithMetadata, amount: u128, - ) -> LezResult { + ) -> SpelResult { // ... business logic ... - Ok(LezOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new(vault.account.clone()), AccountPostState::new(depositor.account.clone()), ])) @@ -98,7 +98,7 @@ pub enum Instruction { ## `#[instruction]` -**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) +**Crate:** `spel-framework-macros` (re-exported by `spel-framework`) Marker attribute for functions inside an `#[lez_program]` module. This attribute is processed by `#[lez_program]` — it is a no-op when used standalone. @@ -118,12 +118,12 @@ pub fn instruction_name( // Instruction arguments — after all account params arg1: u64, arg2: String, -) -> LezResult { +) -> SpelResult { // ... } ``` -- **Return type** must be `LezResult` (alias for `Result`). +- **Return type** must be `SpelResult` (alias for `Result`). - **Account parameters** are identified by type: `AccountWithMetadata` for single accounts, `Vec` for variable-length. - **All other parameters** are instruction arguments and become fields in the generated `Instruction` enum variant. - Function names use `snake_case`; generated enum variants use `PascalCase`. @@ -185,14 +185,14 @@ The `pda` attribute specifies how the account address is derived. Seed types: ## `generate_idl!` -**Crate:** `lez-framework-macros` (re-exported by `lez-framework`) +**Crate:** `spel-framework-macros` (re-exported by `spel-framework`) Proc macro that reads a Rust source file at compile time, finds the `#[lez_program]` module, and generates a `fn main()` that prints the complete IDL as JSON. ### Syntax ```rust -lez_framework::generate_idl!("path/to/program.rs"); +spel_framework::generate_idl!("path/to/program.rs"); ``` The path is resolved relative to `CARGO_MANIFEST_DIR`. The file must contain exactly one `#[lez_program]` module. @@ -213,7 +213,7 @@ Create a binary crate (e.g., `examples/src/bin/generate_idl.rs`): /// /// Usage: /// cargo run --bin generate_idl > my_program-idl.json -lez_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); +spel_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); ``` Then run: @@ -237,7 +237,7 @@ The `#[lez_program]` macro generates validation functions for instructions that ```rust pub fn __validate_{instruction_name}( accounts: &[AccountWithMetadata] -) -> Result<(), LezError> +) -> Result<(), SpelError> ``` **Checks performed (in order):** @@ -245,7 +245,7 @@ pub fn __validate_{instruction_name}( 1. **Signer checks** — for each `#[account(signer)]`: ```rust if !accounts[idx].is_authorized { - return Err(LezError::Unauthorized { + return Err(SpelError::Unauthorized { message: "Account '{name}' (index {idx}) must be a signer", }); } @@ -254,7 +254,7 @@ pub fn __validate_{instruction_name}( 2. **Init checks** — for each `#[account(init)]`: ```rust if accounts[idx].account != Account::default() { - return Err(LezError::AccountAlreadyInitialized { + return Err(SpelError::AccountAlreadyInitialized { account_index: idx, }); } @@ -266,11 +266,11 @@ If an instruction has no `signer` or `init` constraints, no validation function ### Validation Helpers -The `lez-framework-core::validation` module provides helper functions used by generated code: +The `spel-framework-core::validation` module provides helper functions used by generated code: | Function | Signature | Description | |----------|-----------|-------------| -| `validate_account_count` | `fn(actual: usize, expected: usize) -> Result<(), LezError>` | Check that the correct number of accounts was provided. Returns `AccountCountMismatch` on failure. | -| `validate_accounts` | `fn(account_count: usize, constraints: &[AccountConstraint]) -> Result<(), LezError>` | Validate accounts against constraints. Currently checks count; ownership, init state, signer, and PDA checks are delegated to the macro-generated code. | +| `validate_account_count` | `fn(actual: usize, expected: usize) -> Result<(), SpelError>` | Check that the correct number of accounts was provided. Returns `AccountCountMismatch` on failure. | +| `validate_accounts` | `fn(account_count: usize, constraints: &[AccountConstraint]) -> Result<(), SpelError>` | Validate accounts against constraints. Currently checks count; ownership, init state, signer, and PDA checks are delegated to the macro-generated code. | | `is_default_account` | `fn(data: &[u8]) -> bool` | Check if account data is empty or all zeros. Used for `init` constraint. | -| `verify_owner` | `fn(account_owner: &[u8; 32], expected_owner: &[u8; 32], account_index: usize) -> Result<(), LezError>` | Verify account ownership. Returns `InvalidAccountOwner` on mismatch. | +| `verify_owner` | `fn(account_owner: &[u8; 32], expected_owner: &[u8; 32], account_index: usize) -> Result<(), SpelError>` | Verify account ownership. Returns `InvalidAccountOwner` on mismatch. | diff --git a/docs/reference/types.md b/docs/reference/types.md index cd5987a6..6373867e 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -1,17 +1,17 @@ # Types -Core types provided by `lez-framework-core` for building LEZ programs. These include return types, error types, account constraint metadata, and re-exported types from `nssa_core`. +Core types provided by `spel-framework-core` for building LEZ programs. These include return types, error types, account constraint metadata, and re-exported types from `nssa_core`. For a guided walkthrough, see the [Tutorial](../tutorial.md). For other reference topics, see the [Reference Index](README.md). --- -## `LezOutput` +## `SpelOutput` Return value from instruction handlers. Contains post-states and optional chained calls. ```rust -pub struct LezOutput { +pub struct SpelOutput { pub post_states: Vec, pub chained_calls: Vec, } @@ -30,13 +30,13 @@ pub struct LezOutput { ```rust // Most instructions: just return updated states -Ok(LezOutput::states_only(vec![ +Ok(SpelOutput::states_only(vec![ AccountPostState::new_claimed(new_account), // init AccountPostState::new(updated_account), // update ])) // Cross-program call -Ok(LezOutput::with_chained_calls( +Ok(SpelOutput::with_chained_calls( vec![AccountPostState::new(state.account.clone())], vec![chained_call], )) @@ -44,25 +44,25 @@ Ok(LezOutput::with_chained_calls( --- -## `LezResult` +## `SpelResult` Type alias for instruction handler return types: ```rust -pub type LezResult = Result; +pub type SpelResult = Result; ``` -All `#[instruction]` functions must return `LezResult`. +All `#[instruction]` functions must return `SpelResult`. --- -## `LezError` +## `SpelError` Structured error type for LEZ programs. Borsh-serializable for on-chain representation. ```rust #[derive(Error, Debug, BorshSerialize, BorshDeserialize)] -pub enum LezError { /* ... */ } +pub enum SpelError { /* ... */ } ``` ### Variants @@ -93,14 +93,14 @@ pub enum LezError { /* ... */ } ```rust // Using built-in variants if balance < amount { - return Err(LezError::InsufficientBalance { + return Err(SpelError::InsufficientBalance { available: balance, requested: amount, }); } // Using custom errors -return Err(LezError::custom(1, "Proposal already executed")); +return Err(SpelError::custom(1, "Proposal already executed")); // error_code() returns 6001 ``` @@ -152,14 +152,14 @@ pub struct ArgMeta { ## Re-exported nssa_core types -The following types are re-exported through `lez-framework-core::prelude`: +The following types are re-exported through `spel-framework-core::prelude`: | Type | Origin | Description | |------|--------|-------------| | `Account` | `nssa_core::account` | Generic account wrapper | | `AccountWithMetadata` | `nssa_core::account` | Account data plus `account_id` and `is_authorized` flag. Primary type used in instruction handlers. | | `AccountPostState` | `nssa_core::program` | Represents the state of an account after instruction execution. Use `new()` for existing accounts, `new_claimed()` for newly initialized accounts. | -| `ChainedCall` | `nssa_core::program` | Cross-program invocation data. Returned in `LezOutput::with_chained_calls()`. | +| `ChainedCall` | `nssa_core::program` | Cross-program invocation data. Returned in `SpelOutput::with_chained_calls()`. | | `PdaSeed` | `nssa_core::program` | A 32-byte PDA seed. Constructed via `PdaSeed::new(bytes)`. | | `ProgramId` | `nssa_core::program` | Type alias for `[u32; 8]`. Identifies a program by its RISC Zero image ID. | @@ -170,14 +170,14 @@ The following types are re-exported through `lez-framework-core::prelude`: Import the prelude for convenient access to common types: ```rust -use lez_framework::prelude::*; +use spel_framework::prelude::*; ``` This imports: - `lez_program` (macro) - `instruction` (macro) -- `LezOutput` -- `LezError`, `LezResult` +- `SpelOutput` +- `SpelError`, `SpelResult` - `AccountConstraint` - `Account`, `AccountWithMetadata` - `AccountPostState`, `ChainedCall`, `PdaSeed`, `ProgramId` diff --git a/docs/tutorial.md b/docs/tutorial.md index ee9b3982..35fed473 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -36,21 +36,21 @@ Before you begin, make sure you have: - **RISC Zero toolchain** — [install instructions](https://dev.risczero.com/api/zkvm/install) - **NSSA wallet CLI** (`wallet` binary) — for account creation and transaction signing - A **running sequencer** — the network node that accepts transactions -- **lez-cli** installed: +- **spel** installed: ```bash # From the SPEL repo -cargo install --path lez-cli +cargo install --path spel-cli # installs as the `spel` binary ``` --- ## Step 1: Scaffold the Project -Use `lez-cli init` to create a new project: +Use `spel init` to create a new project: ```bash -lez-cli init my-counter +spel init my-counter cd my-counter ``` @@ -117,7 +117,7 @@ This is the core of your program. Edit `methods/guest/src/bin/my_counter.rs`: use nssa_core::account::AccountWithMetadata; use nssa_core::program::AccountPostState; -use lez_framework::prelude::*; +use spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); @@ -136,20 +136,20 @@ mod my_counter { counter: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, - ) -> LezResult { + ) -> SpelResult { // Serialize initial state into the account data let state = my_counter_core::CounterState { count: 0, owner: *owner.account_id.value(), }; - let data = borsh::to_vec(&state).map_err(|e| LezError::SerializationError { + let data = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { message: e.to_string(), })?; let mut new_account = counter.account.clone(); new_account.data = data.try_into().unwrap(); - Ok(LezOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new_claimed(new_account), AccountPostState::new(owner.account.clone()), ])) @@ -166,11 +166,11 @@ mod my_counter { #[account(signer)] owner: AccountWithMetadata, amount: u64, - ) -> LezResult { + ) -> SpelResult { // Deserialize current state let mut state: my_counter_core::CounterState = borsh::from_slice(&counter.account.data).map_err(|e| { - LezError::DeserializationError { + SpelError::DeserializationError { account_index: 0, message: e.to_string(), } @@ -178,24 +178,24 @@ mod my_counter { // Verify the signer is the owner if *owner.account_id.value() != state.owner { - return Err(LezError::Unauthorized { + return Err(SpelError::Unauthorized { message: "Only the owner can increment".to_string(), }); } // Increment with overflow check - state.count = state.count.checked_add(amount).ok_or(LezError::Overflow { + state.count = state.count.checked_add(amount).ok_or(SpelError::Overflow { operation: "counter increment".to_string(), })?; // Serialize updated state - let data = borsh::to_vec(&state).map_err(|e| LezError::SerializationError { + let data = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { message: e.to_string(), })?; let mut updated = counter.account.clone(); updated.data = data.try_into().unwrap(); - Ok(LezOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new(updated), AccountPostState::new(owner.account.clone()), ])) @@ -209,17 +209,17 @@ mod my_counter { pub fn get_count( #[account(pda = literal("counter"))] counter: AccountWithMetadata, - ) -> LezResult { + ) -> SpelResult { let state: my_counter_core::CounterState = borsh::from_slice(&counter.account.data).map_err(|e| { - LezError::DeserializationError { + SpelError::DeserializationError { account_index: 0, message: e.to_string(), } })?; // Return account unchanged (read-only) - Ok(LezOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new(counter.account.clone()), ])) } @@ -232,7 +232,7 @@ Let's break down what's happening: 1. **`#[lez_program]`** — wraps your module and generates the guest `main()`, instruction dispatch, and IDL. -2. **`#[instruction]`** — marks each function as an on-chain instruction. The function name becomes a CLI subcommand (e.g., `increment` → `lez-cli increment`). +2. **`#[instruction]`** — marks each function as an on-chain instruction. The function name becomes a CLI subcommand (e.g., `increment` → `spel increment`). 3. **`#[account(init, pda = literal("counter"))]`** — the counter account is a PDA (Program Derived Address) derived from the string `"counter"` and the program ID. The `init` constraint means this account must not already exist. @@ -254,7 +254,7 @@ The scaffold already created `examples/src/bin/generate_idl.rs`. Make sure it po ```rust /// Generate IDL JSON for the my-counter program. -lez_framework::generate_idl!("../methods/guest/src/bin/my_counter.rs"); +spel_framework::generate_idl!("../methods/guest/src/bin/my_counter.rs"); ``` This reads your program source at compile time and generates a `main()` that prints the complete IDL as JSON. The IDL describes all instructions, their accounts, arguments, PDA seeds, and types. @@ -268,7 +268,7 @@ The scaffold already created `examples/src/bin/my_counter_cli.rs`: ```rust #[tokio::main] async fn main() { - lez_cli::run().await; + spel_cli::run().await; } ``` @@ -400,10 +400,24 @@ Save the hex ImageID — you'll need it for CLI commands. ## Step 8: Interact with Your Program +### Set up `spel.toml` (optional, recommended) + +The scaffold creates a `spel.toml` in your project root: + +```toml +[program] +idl = "my-counter-idl.json" +binary = "methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin" +``` + +With this file in place, `spel` auto-discovers the IDL and binary — you can drop `-i`/`-p` flags and call subcommands directly. All examples below show both variants. + ### See available commands ```bash -make cli ARGS="--help" +spel --help # with spel.toml +# or +spel --idl my-counter-idl.json --help ``` Output: @@ -412,7 +426,8 @@ Output: 🔧 my_counter v0.1.0 — IDL-driven CLI USAGE: - my_counter_cli [OPTIONS] [ARGS] + spel [ARGS] (with spel.toml) + spel [OPTIONS] -- [ARGS] (without spel.toml) COMMANDS: inspect [FILE...] Print ProgramId for ELF binary(ies) @@ -427,16 +442,35 @@ Notice how the CLI auto-generated commands from your IDL: - Instruction arguments (`amount`) are typed - Account arguments (`owner`) expect base58 or hex +### The `--` separator + +When invoking `spel` **without** a `spel.toml`, global options (`--idl`, `--program`, `--dry-run`) must come before a `--` separator, and the instruction plus its `--arg` flags come after: + +```bash +spel --idl my-counter-idl.json --program ./my_counter.bin -- \ + increment --amount 5 --owner-account +``` + +Without `--`, the first `--amount` would be consumed as a global flag and error out. With a `spel.toml`, no separator is needed because there are no global flags in play. + ### Initialize the counter +With `spel.toml`: + +```bash +spel initialize --owner-account +``` + +Without `spel.toml` (via `make cli`, which forwards `ARGS`): + ```bash -make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin \ +make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin -- \ initialize --owner-account " ``` The CLI will: -1. Load the program binary to get the ProgramId -2. Compute the `counter` PDA from the seed `"counter"` + ProgramId +1. Load the program binary (or resolve it from `spel.toml`) to get the ProgramId +2. Compute the `counter` PDA from the seed `"counter"` + ProgramId (and print the seeds it used) 3. Fetch the nonce for the signer account from the wallet 4. Build and sign the transaction 5. Submit to the sequencer @@ -445,37 +479,65 @@ The CLI will: ### Increment the counter ```bash -make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin \ - increment --amount 5 --owner-account " +spel increment --amount 5 --owner-account ``` -### Use `--program-id` to skip loading the binary +### Pass a raw program ID instead of a binary -If you know the program ID, you can skip loading the binary: +`--program` accepts three forms: a name from `spel.toml`, a 64-character hex program ID, or a file path to the ELF binary. Using the hex ID skips loading the binary and is faster: ```bash -make cli ARGS="--program-id <64-CHAR-HEX> \ - increment --amount 10 --owner-account " +spel --idl my-counter-idl.json --program <64-CHAR-HEX> -- \ + increment --amount 10 --owner-account ``` ### Dry run (no submission) -Add `--dry-run` to see what would be submitted without actually sending: +`--dry-run` resolves the whole transaction (PDAs, accounts, signer nonces, serialized data) and prints it without submitting: + +```bash +spel --dry-run increment --amount 5 --owner-account +``` + +Typical text output: + +``` +=== Dry Run === +Program ID: 3930000032920100... +Instruction: increment + +Accounts: + PDA counter → 4Lp3gkH... [writable] + seeds: [program_id, "counter"] + owner → 0xccdd...00 [signer] + +Arguments: + --amount 5 + +Instruction data: 0x010000000500000000000000 + +Signers: + owner: nonce=42 +================ +Dry run complete — not submitted. +``` + +For machine-readable output (e.g. in CI golden tests or `jq` pipelines), use `--dry-run=json`: ```bash -make cli ARGS="--dry-run -p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin \ - increment --amount 5 --owner-account " +spel --dry-run=json increment --amount 5 --owner-account | jq . ``` -This prints the parsed arguments, serialized instruction data, and account IDs without submitting. +In JSON mode all human preamble is suppressed — only the JSON document goes to stdout. ### Compute the counter PDA manually ```bash -make cli ARGS="--program-id <64-CHAR-HEX> pda counter" +spel pda counter # with spel.toml +spel --idl my-counter-idl.json --program pda counter ``` -This prints the base58 AccountId of the counter PDA. +This prints the base58 AccountId of the counter PDA. The output also echoes the seed inputs used for derivation, e.g. `seeds: [program_id, "counter"]` — useful for debugging PDA mismatches across clients. --- @@ -540,14 +602,14 @@ fn main() { **3. Validation Functions** ```rust -pub fn __validate_initialize(accounts: &[AccountWithMetadata]) -> Result<(), LezError> { +pub fn __validate_initialize(accounts: &[AccountWithMetadata]) -> Result<(), SpelError> { // init check: counter must be default if accounts[0].account != Account::default() { - return Err(LezError::AccountAlreadyInitialized { account_index: 0 }); + return Err(SpelError::AccountAlreadyInitialized { account_index: 0 }); } // signer check: owner must be authorized if !accounts[1].is_authorized { - return Err(LezError::Unauthorized { + return Err(SpelError::Unauthorized { message: "Account 'owner' (index 1) must be a signer".to_string(), }); } @@ -559,7 +621,7 @@ pub fn __validate_initialize(accounts: &[AccountWithMetadata]) -> Result<(), Lez ```rust pub const PROGRAM_IDL_JSON: &str = r#"{"version":"0.1.0","name":"my_counter",...}"#; -pub fn __program_idl() -> LezIdl { ... } +pub fn __program_idl() -> SpelIdl { ... } ``` ### Account Validation @@ -568,8 +630,8 @@ The framework generates automatic validation checks that run before your handler | Attribute | Check | Error | |-----------|-------|-------| -| `signer` | `is_authorized == true` | `LezError::Unauthorized` | -| `init` | `account == Account::default()` | `LezError::AccountAlreadyInitialized` | +| `signer` | `is_authorized == true` | `SpelError::Unauthorized` | +| `init` | `account == Account::default()` | `SpelError::AccountAlreadyInitialized` | These checks are generated per-instruction. If an instruction has no `signer` or `init` accounts, no validation function is generated. @@ -633,7 +695,7 @@ mod multisig { } ``` -The IDL will include `"instruction_type": "multisig_core::Instruction"`, which tells `lez-client-gen` to import and use the shared type in generated FFI code. +The IDL will include `"instruction_type": "multisig_core::Instruction"`, which tells `spel-client-gen` to import and use the shared type in generated FFI code. ### Variable-Length Accounts @@ -646,7 +708,7 @@ pub fn multi_approve( state: AccountWithMetadata, #[account(signer)] members: Vec, -) -> LezResult { +) -> SpelResult { // members can contain 0, 1, 2, ... accounts for member in &members { // validate each member @@ -658,7 +720,7 @@ pub fn multi_approve( In the CLI, pass rest accounts as a comma-separated list: ```bash -lez-cli ... multi-approve --members-account "addr1,addr2,addr3" +spel multi-approve --members-account "addr1,addr2,addr3" ``` Rest accounts are always optional (0 entries is valid). The macro splits `pre_states` into fixed accounts (before the rest) and the variadic tail. @@ -672,14 +734,14 @@ Instructions can trigger calls to other programs by returning `ChainedCall`s: pub fn transfer_and_notify( // ... accounts ... amount: u64, -) -> LezResult { +) -> SpelResult { // ... transfer logic ... let chained_call = ChainedCall { // ... target program and instruction data ... }; - Ok(LezOutput::with_chained_calls( + Ok(SpelOutput::with_chained_calls( vec![/* post states */], vec![chained_call], )) @@ -688,10 +750,10 @@ pub fn transfer_and_notify( ### Client Code Generation -For integrating LEZ programs into applications (e.g., a C++/Qt desktop app), use `lez-client-gen` to generate typed bindings: +For integrating LEZ programs into applications (e.g., a C++/Qt desktop app), use `spel-client-gen` to generate typed bindings: ```bash -lez-client-gen --idl my-counter-idl.json --out-dir generated/ +spel-client-gen --idl my-counter-idl.json --out-dir generated/ ``` This produces three files: @@ -738,7 +800,7 @@ cargo build --release --lib - **Read the [Reference](reference/README.md)** for complete API documentation - **Study [lez-multisig](https://github.com/logos-co/lez-multisig)** for a production-quality example with multi-seed PDAs, variable-length accounts, and external instruction enums -- **Generate client code** with `lez-client-gen` for integrating your program into applications +- **Generate client code** with `spel-client-gen` for integrating your program into applications - **Write tests** — the `#[cfg(not(test))]` gate on `main()` means your handlers are directly callable in host-side tests: ```rust diff --git a/skills/spel/SKILL.md b/skills/spel/SKILL.md index 64d1500f..c758999a 100644 --- a/skills/spel/SKILL.md +++ b/skills/spel/SKILL.md @@ -1,13 +1,13 @@ --- name: spel -description: "Build, deploy, and interact with LEZ on-chain programs using the SPEL framework (logos-co/spel). Use when: (1) creating a new LEZ program or SPEL project, (2) writing #[lez_program] instructions with account constraints, PDA derivation, or signer checks, (3) generating IDL from program source, (4) using lez-cli to deploy, inspect, call instructions, or compute PDAs, (5) generating typed Rust/C FFI client code with lez-client-gen, (6) debugging SPEL macro output, account validation, or PDA mismatches, (7) registering a program in SPELbook, or (8) any mention of lez-cli, lez_framework, lez-client-gen, #[lez_program], #[instruction], LezOutput, LezError, LezResult, generate_idl!, AccountPostState, PdaSeed, or RISC Zero zkVM guest programs in the LEZ/NSSA ecosystem." +description: "Build, deploy, and interact with LEZ on-chain programs using the SPEL framework (logos-co/spel). Use when: (1) creating a new LEZ program or SPEL project, (2) writing #[lez_program] instructions with account constraints, PDA derivation, or signer checks, (3) generating IDL from program source, (4) using spel to deploy, inspect, call instructions, or compute PDAs, (5) generating typed Rust/C FFI client code with spel-client-gen, (6) debugging SPEL macro output, account validation, or PDA mismatches, (7) registering a program in SPELbook, or (8) any mention of spel, spel_framework, spel-client-gen, #[lez_program], #[instruction], SpelOutput, SpelError, SpelResult, generate_idl!, AccountPostState, PdaSeed, or RISC Zero zkVM guest programs in the LEZ/NSSA ecosystem." --- # SPEL Framework SPEL is a Rust framework for building on-chain programs that run on LEZ (the NSSA execution layer). It provides attribute macros (`#[lez_program]`, `#[instruction]`) that generate zkVM guest binaries, instruction dispatch, account validation, IDL, and CLI tooling from annotated Rust modules. -Programs compile to RISC Zero zkVM guests. The framework auto-generates an `Instruction` enum, `main()` dispatch, validation functions, and a full IDL (JSON). The `lez-cli` reads the IDL at runtime to provide a complete CLI for any program. +Programs compile to RISC Zero zkVM guests. The framework auto-generates an `Instruction` enum, `main()` dispatch, validation functions, and a full IDL (JSON). The `spel` CLI reads the IDL at runtime to provide a complete CLI for any program — with a `spel.toml` in the project root, flags are optional and instructions can be invoked as bare subcommands (`spel initialize --owner-account …`). ## References @@ -15,18 +15,18 @@ Read these files as needed: - **[references/quickstart.md](references/quickstart.md)** — Full scaffold-to-deploy workflow with real commands. Read when building a new program or recalling the build/deploy/call sequence. - **[references/gotchas.md](references/gotchas.md)** — Hard-won lessons and common mistakes. Read before writing or debugging any SPEL program. -- **[references/cli-ref.md](references/cli-ref.md)** — CLI cheatsheet for `lez-cli` and `lez-client-gen`. Read when constructing CLI commands or checking flag names. +- **[references/cli-ref.md](references/cli-ref.md)** — CLI cheatsheet for `spel` and `spel-client-gen`. Read when constructing CLI commands or checking flag names. ## Core Workflow -1. **Scaffold** — `lez-cli init ` creates workspace with guest binary, core crate, IDL generator, CLI wrapper, and Makefile. +1. **Scaffold** — `spel init ` creates workspace with guest binary, core crate, IDL generator, CLI wrapper, and Makefile. 2. **Define state** — Put shared types in `{name}_core/src/lib.rs` (must derive `Serialize`, `Deserialize`). 3. **Write instructions** — In `methods/guest/src/bin/{name}.rs`, use `#[lez_program]` + `#[instruction]` with account constraints (`signer`, `init`, `mut`, `pda`, `owner`). 4. **Build** — `make build` compiles the RISC Zero zkVM guest binary. 5. **Generate IDL** — `make idl` runs `generate_idl!` macro to emit `{name}-idl.json`. 6. **Deploy** — `make setup && make deploy` creates signer account and deploys binary. -7. **Call instructions** — `make cli ARGS="..."` with IDL-driven subcommands. -8. **Generate client code** — `lez-client-gen --idl --out-dir ` for Rust client + C FFI + C header. +7. **Call instructions** — with `spel.toml` (scaffolded by default): `spel -- `. Without: `spel [OPTIONS] -- -- ` (the `--` separates global flags from instruction flags). `--dry-run[=text|json]` previews the full resolved transaction without submitting. +8. **Generate client code** — `spel-client-gen --idl --out-dir ` for Rust client + C FFI + C header. ## Key Instruction Patterns @@ -53,10 +53,10 @@ AccountPostState::new_claimed(account) AccountPostState::new(account) // Return with no chained calls (most common) -Ok(LezOutput::states_only(vec![...])) +Ok(SpelOutput::states_only(vec![...])) // Return with cross-program calls -Ok(LezOutput::with_chained_calls(vec![...], vec![chained_call])) +Ok(SpelOutput::with_chained_calls(vec![...], vec![chained_call])) ``` ### Variable-length accounts diff --git a/skills/spel/references/cli-ref.md b/skills/spel/references/cli-ref.md index 517f2ddf..f37aa4a8 100644 --- a/skills/spel/references/cli-ref.md +++ b/skills/spel/references/cli-ref.md @@ -1,18 +1,48 @@ # CLI Reference -Condensed cheatsheet for `lez-cli` and `lez-client-gen`. +Condensed cheatsheet for `spel` and `spel-client-gen`. --- -## lez-cli Global Options +## Invocation syntax + +``` +spel [ARGS] (with spel.toml) +spel [OPTIONS] -- [ARGS] (without spel.toml) +``` + +The `--` separator is required whenever global `OPTIONS` are mixed with a command that has its own `--flags`. + +## spel.toml (optional) + +Place in project root to skip `--idl`/`--program` flags. Discovered by walking up from CWD. + +```toml +[program] # single-program +idl = "my-idl.json" +binary = "target/prog.bin" + +# or + +[programs.game] # multi-program; select with `--program game` +idl = "game-idl.json" +binary = "target/game.bin" +[programs.nft] +idl = "nft-idl.json" +binary = "target/nft.bin" +``` + +Paths resolve relative to the `spel.toml` itself. `[program]` and `[programs]` are mutually exclusive. + +## Global Options | Option | Short | Description | |--------|-------|-------------| -| `--idl ` | `-i` | IDL JSON file path (required for most commands) | -| `--program ` | `-p` | Program ELF binary (computes ProgramId) | -| `--program-id ` | | 64-char hex ProgramId (overrides `--program`) | -| `--dry-run` | | Print parsed data without submitting transaction | +| `--idl ` | `-i` | IDL JSON file path (required if not in `spel.toml`) | +| `--program ` | `-p` | Name from `spel.toml`, 64-char hex program ID, or path to ELF binary | +| `--dry-run[=text\|json]` | | Resolve PDAs/accounts/nonces/ix-data and print without submitting (`text` default) | | `--bin- ` | | Additional binary; auto-fills `---program-id` | +| `--program-id ` | | Deprecated — use `--program ` | --- @@ -21,7 +51,7 @@ Condensed cheatsheet for `lez-cli` and `lez-client-gen`. ### init — Scaffold New Project ```bash -lez-cli init +spel init ``` No `--idl` required. Creates full workspace with Makefile, core crate, guest binary, IDL generator, and CLI wrapper. @@ -29,7 +59,7 @@ No `--idl` required. Creates full workspace with Makefile, core crate, guest bin ### inspect — Print ProgramId ```bash -lez-cli inspect [FILE...] +spel inspect [FILE...] ``` No `--idl` required. Outputs decimal, hex, and ImageID formats for each binary. @@ -37,13 +67,13 @@ No `--idl` required. Outputs decimal, hex, and ImageID formats for each binary. ``` ProgramId (decimal): 12345,67890,... ProgramId (hex): 00003039,000109b2,... -ImageID (hex bytes): 393000009b210100... ← use this for --program-id +ImageID (hex bytes): 393000009b210100... ← pass to `--program ` ``` ### idl — Print IDL ```bash -lez-cli -i idl +spel -i idl ``` Pretty-prints the loaded IDL JSON. @@ -51,42 +81,45 @@ Pretty-prints the loaded IDL JSON. ### pda (IDL mode) — Compute PDA from IDL Seeds ```bash -lez-cli -i [-p | --program-id ] pda [-- ] +spel -i -p pda [-- ] ``` -Looks up account in IDL, resolves seeds, prints base58 address. +Looks up account in IDL, resolves seeds, prints base58 address plus seed inputs. ```bash -# Const-only seeds -lez-cli -i idl.json --program-id abc...def pda counter +# With spel.toml +spel pda counter +spel pda multisig_state --create-key 0a1b2c... -# With arg seed -lez-cli -i idl.json --program-id abc...def pda multisig_state --create-key 0a1b2c... - -# With account seed -lez-cli -i idl.json --program-id abc...def pda vault --user-account EjR7... +# Without spel.toml +spel -i idl.json -p abc...def pda counter +spel -i idl.json -p abc...def pda vault --user-account EjR7... # List all PDAs -lez-cli -i idl.json pda +spel -i idl.json pda ``` ### pda (raw mode) — Compute PDA Without IDL ```bash -lez-cli --program-id <64-CHAR-HEX> pda [SEED2] ... +spel --program <64-CHAR-HEX> pda [SEED2] ... ``` No `--idl` required. Each seed: 64-char hex → 32 raw bytes; otherwise UTF-8 zero-padded to 32 bytes. Multi-seed: `SHA-256(seed1 || seed2 || ...)`. ```bash -lez-cli --program-id abc...def pda my_state -lez-cli --program-id abc...def pda multisig_vault__ 0a1b2c3d... +spel --program abc...def pda my_state +spel --program abc...def pda multisig_vault__ 0a1b2c3d... ``` ### Instruction Execution ```bash -lez-cli -i [-p | --program-id ] [-- ] [---account ] +# With spel.toml +spel [-- ] [---account ] + +# Without spel.toml (`--` is REQUIRED when mixing global flags with instruction flags) +spel -i -p -- [-- ] [---account ] ``` - Instruction names: `snake_case` → `kebab-case` (`create_proposal` → `create-proposal`) @@ -95,22 +128,26 @@ lez-cli -i [-p | --program-id ] [-- ] [ - Rest accounts: comma-separated list ```bash -# Execute instruction -lez-cli -i idl.json -p prog.bin create \ - --create-key 0a1b... --threshold 2 \ - --members "aa...00,bb...00" \ - --creator-account EjR7... +# Execute instruction (with spel.toml) +spel create --create-key 0a1b... --threshold 2 \ + --members "aa...00,bb...00" --creator-account EjR7... + +# Same, without spel.toml +spel -i idl.json -p prog.bin -- create --create-key 0a1b... --threshold 2 \ + --members "aa...00,bb...00" --creator-account EjR7... + +# Dry run (text, default) +spel --dry-run approve --proposal-id 5 --member-account cc...00 -# Dry run -lez-cli -i idl.json --program-id abc...def --dry-run approve \ - --proposal-id 5 --member-account cc...00 +# Dry run (JSON — pipe through jq in CI) +spel --dry-run=json approve --proposal-id 5 --member-account cc...00 | jq . # Cross-program binary reference -lez-cli -i treasury-idl.json -p treasury.bin --bin-token token.bin \ +spel -i treasury-idl.json -p treasury.bin --bin-token token.bin -- \ transfer --amount 100 --from-account aa...00 # Per-instruction help -lez-cli -i idl.json --help +spel --help ``` --- @@ -135,12 +172,12 @@ lez-cli -i idl.json --help --- -## lez-client-gen +## spel-client-gen Generate typed Rust client + C FFI + C header from IDL: ```bash -lez-client-gen --idl --out-dir +spel-client-gen --idl --out-dir ``` | Option | Required | Description | diff --git a/skills/spel/references/gotchas.md b/skills/spel/references/gotchas.md index e51101f6..b6d8e07a 100644 --- a/skills/spel/references/gotchas.md +++ b/skills/spel/references/gotchas.md @@ -12,12 +12,12 @@ Every account passed to an instruction must appear in the `post_states` vector, ```rust // WRONG — forgot to return owner -Ok(LezOutput::states_only(vec![ +Ok(SpelOutput::states_only(vec![ AccountPostState::new(updated_state), ])) // RIGHT — return all accounts -Ok(LezOutput::states_only(vec![ +Ok(SpelOutput::states_only(vec![ AccountPostState::new(updated_state), AccountPostState::new(owner.account.clone()), ])) @@ -67,7 +67,7 @@ When using multiple seeds (`pda = [literal("a"), account("b")]`), they are combi ### Program ID is [u32; 8], not [u8; 32] -The Program ID is the RISC Zero ImageID — an array of 8 u32 values. When converting from hex, use `from_le_bytes` for each u32 chunk (little-endian byte order). The 64-char hex ImageID from `lez-cli inspect` is the little-endian byte representation. +The Program ID is the RISC Zero ImageID — an array of 8 u32 values. When converting from hex, use `from_le_bytes` for each u32 chunk (little-endian byte order). The 64-char hex ImageID from `spel inspect` is the little-endian byte representation. ### bytemuck for raw PDA mode @@ -96,11 +96,11 @@ In `#[instruction]` function signatures, all `AccountWithMetadata` / `Vec LezResult { ... } +pub fn bad(amount: u64, #[account(signer)] user: AccountWithMetadata) -> SpelResult { ... } // RIGHT — accounts first, then arguments #[instruction] -pub fn good(#[account(signer)] user: AccountWithMetadata, amount: u64) -> LezResult { ... } +pub fn good(#[account(signer)] user: AccountWithMetadata, amount: u64) -> SpelResult { ... } ``` ### Vec must be the last account parameter @@ -125,9 +125,28 @@ If you pass an empty string `""` as an instruction argument, logoscore may silen `create_proposal` in Rust becomes `create-proposal` in the CLI. Account flags use `--{name}-account` suffix. -### --program-id expects 64-char hex (ImageID format) +### `--program` accepts name, hex, or file path -The `--program-id` flag expects the 64-character hex string from `lez-cli inspect` (ImageID hex bytes), not the decimal or comma-separated format. +`--program ` resolves in three ways: +1. If `` matches a name in `spel.toml`'s `[programs.]`, the IDL and binary are loaded from that entry. +2. Else, if `` is a 64-character hex string, it's used directly as the program ID (no binary load). +3. Else, `` is treated as a file path to the ELF binary. + +The hex form replaces the deprecated `--program-id `. The 64-char hex comes from `spel inspect` (ImageID hex bytes row), not the decimal or comma-separated format. + +### Mix global and instruction flags with `--` + +Without a `spel.toml`, global options (`--idl`, `--program`, `--dry-run`) come before a `--` separator; the instruction and its `--arg` flags come after. Without `--`, the first instruction `--flag` is swallowed by the global parser and the command errors out. + +```bash +spel -i idl.json -p prog.bin -- increment --amount 5 --owner-account +``` + +With `spel.toml` there are no global flags in play, so `--` is not needed. + +### `--dry-run` has two formats + +`--dry-run` and `--dry-run=text` print a human-readable summary (accounts, arguments, instruction data, signer nonces). `--dry-run=json` emits a JSON document with the same data — in JSON mode, all human output is suppressed so the result is pipeable to `jq`. Numeric values over 53 bits (`u128`, nonces) are emitted as decimal strings to avoid precision loss. ### Account IDs accept both base58 and hex diff --git a/skills/spel/references/quickstart.md b/skills/spel/references/quickstart.md index 40a5e00e..0d23b25a 100644 --- a/skills/spel/references/quickstart.md +++ b/skills/spel/references/quickstart.md @@ -7,7 +7,7 @@ Full workflow for creating, building, deploying, and interacting with a LEZ prog ## 1. Scaffold ```bash -lez-cli init my-program +spel init my-program cd my-program ``` @@ -17,6 +17,7 @@ Generated structure: my-program/ ├── Cargo.toml # workspace ├── Makefile # build, idl, cli, deploy, inspect, setup targets +├── spel.toml # [program] config — `spel` auto-discovers idl/binary ├── my_program_core/src/lib.rs # shared types ├── methods/guest/src/bin/my_program.rs # on-chain guest binary ├── examples/src/bin/ @@ -50,7 +51,7 @@ Edit `methods/guest/src/bin/my_program.rs`: use nssa_core::account::AccountWithMetadata; use nssa_core::program::AccountPostState; -use lez_framework::prelude::*; +use spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); @@ -65,16 +66,16 @@ mod my_program { state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, - ) -> LezResult { + ) -> SpelResult { let data = borsh::to_vec(&my_program_core::MyState { value: 0, owner: *owner.account_id.value(), - }).map_err(|e| LezError::SerializationError { message: e.to_string() })?; + }).map_err(|e| SpelError::SerializationError { message: e.to_string() })?; let mut new_account = state.account.clone(); new_account.data = data.try_into().unwrap(); - Ok(LezOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new_claimed(new_account), AccountPostState::new(owner.account.clone()), ])) @@ -87,26 +88,26 @@ mod my_program { #[account(signer)] owner: AccountWithMetadata, new_value: u64, - ) -> LezResult { + ) -> SpelResult { let mut current: my_program_core::MyState = borsh::from_slice(&state.account.data) - .map_err(|e| LezError::DeserializationError { + .map_err(|e| SpelError::DeserializationError { account_index: 0, message: e.to_string(), })?; if *owner.account_id.value() != current.owner { - return Err(LezError::Unauthorized { + return Err(SpelError::Unauthorized { message: "Only the owner can update".to_string(), }); } current.value = new_value; let data = borsh::to_vec(¤t) - .map_err(|e| LezError::SerializationError { message: e.to_string() })?; + .map_err(|e| SpelError::SerializationError { message: e.to_string() })?; let mut updated = state.account.clone(); updated.data = data.try_into().unwrap(); - Ok(LezOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new(updated), AccountPostState::new(owner.account.clone()), ])) @@ -119,7 +120,7 @@ mod my_program { `examples/src/bin/generate_idl.rs` (scaffold creates this): ```rust -lez_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); +spel_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); ``` Path is relative to `CARGO_MANIFEST_DIR` (the `examples/` crate). @@ -131,7 +132,7 @@ Path is relative to `CARGO_MANIFEST_DIR` (the `examples/` crate). ```rust #[tokio::main] async fn main() { - lez_cli::run().await; + spel_cli::run().await; } ``` @@ -159,33 +160,40 @@ Save the 64-char hex ImageID from `make inspect` output. ## 9. Call Instructions +With the scaffold-generated `spel.toml` in the project root, `spel` discovers the IDL and binary automatically — no `-i`/`-p` or `--` separator needed. + ```bash # See available commands -make cli ARGS="--help" +spel --help # Initialize (PDA accounts auto-computed, not passed as args) -make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin \ - initialize --owner-account " +spel initialize --owner-account # Update with argument -make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_program.bin \ - update --new-value 42 --owner-account " +spel update --new-value 42 --owner-account + +# Use a raw 64-char hex program ID to skip binary loading +spel --program <64-CHAR-HEX> -- update --new-value 100 --owner-account -# Use --program-id to skip binary loading -make cli ARGS="--program-id <64-CHAR-HEX> \ - update --new-value 100 --owner-account " +# Dry run (text-default; add =json for machine-readable output) +spel --dry-run update --new-value 5 --owner-account +spel --dry-run=json update --new-value 5 --owner-account | jq . -# Dry run (no submission) -make cli ARGS="--dry-run -p methods/guest/target/...bin update --new-value 5 --owner-account " +# Compute PDA manually — output echoes seed inputs, e.g. `seeds: [program_id, "state"]` +spel pda state +``` -# Compute PDA manually -make cli ARGS="--program-id <64-CHAR-HEX> pda state" +When running without a `spel.toml`, pass `--idl`/`--program` before a `--` separator: + +```bash +spel -i my-program-idl.json -p methods/guest/target/.../my_program.bin -- \ + update --new-value 42 --owner-account ``` ## 10. Generate Client Code (optional) ```bash -lez-client-gen --idl my-program-idl.json --out-dir generated/ +spel-client-gen --idl my-program-idl.json --out-dir generated/ ``` Produces: From f73d6715fcf37b2c92b0371b0d516d98716280b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Fri, 24 Apr 2026 11:24:27 +0200 Subject: [PATCH 7/7] docs: rewrite tutorial to verified patterns after end-to-end walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the tutorial against a live sequencer (spel init → make build → make setup → make deploy → initialize → increment → inspect). Fixes every bug that surfaced plus a few I missed in the takeover pass. Tutorial (docs/tutorial.md): - Renumber to 7 steps (merged Define State + Write Instructions into one "Write the Program" step; merged IDL/CLI-wrapper setup into "Build and Generate IDL"). - Add `spel.toml` to the Step 1 directory tree; explain its role. - Move CounterState into the guest file with `#[account_type]` at file top level (not inside `mod { }` — the IDL generator only scans top-level items). Drop the `_core` crate from the tutorial path; it's noted as optional for genuinely-shared host-side types. - Switch handlers from deprecated `SpelOutput::states_only(vec![AccountPostState::new_claimed(acc, Claim::Authorized), …])` to idiomatic `SpelOutput::execute(vec![acc, …], vec![])`. The macro reads `#[account(init|mut|signer)]` and emits the correct claim. - Add `mut counter: AccountWithMetadata` where handlers write account.account.data. - Drop `use nssa_core::…` imports — everything needed is in `spel_framework::prelude::*` (AccountWithMetadata, SpelOutput, SpelError, Claim, AccountPostState, AutoClaim, BorshSerialize, BorshDeserialize). - Add CounterState `BorshSerialize + BorshDeserialize` derives. - Use `spel generate-idl` instead of `make idl` in Step 4; explain why (proc-macro path currently skips `#[account_type]` markers — see follow-up issue). - Update expected IDL sample with top-level `accounts` entry for CounterState, plus empty `errors` and `types`. - Add a "Read the count back" subsection demonstrating `spel inspect "$(spel pda counter)" --type CounterState` with realistic decoded JSON output. - CLI flags: `--owner-account` → `--owner`, `--members-account` → `--members`, etc. (the `-account` suffix was removed in 021061d). - Correct the `spel pda` description: it prints only the base58 address. The `seeds: [...]` line is rendered during dry-run / live tx output, not by the standalone subcommand. - Fix `spel_cli::run()` → `spel::run()` (crate is named `spel`). - Chained-call example now uses `SpelOutput::execute(accounts, calls)`. Reference files (docs/reference/*.md): - cli.md: `spel_cli::run` → `spel::run`, `---account` → `--` everywhere. - types.md: mark `states_only` and `with_chained_calls` as deprecated in the methods table; rewrite the example to use `execute`; update `AccountPostState` / `ChainedCall` prose. - macros.md: rewrite the sample handler to use `execute`; update the `init` attribute row to describe auto-claim behaviour. Skill (skills/spel/): - SKILL.md: recommend `SpelOutput::execute` in "Return values"; drop `--owner-account` in the description; update the `init` critical-rule wording. - quickstart.md: rewrite Section 2 (drop `_core` for state), Section 3 (modern handler pattern with `#[account_type]`), Section 9 CLI examples (`--owner`, no seed-display claim on `pda`, add `inspect --type`). - cli-ref.md: `---account` → `--`; correct the `pda` subcommand's output description. - gotchas.md: rewrite the "Return ALL accounts" example with `execute`; replace the `new_claimed vs new` + `new_claimed_if_default` sections with a single "Let the macro derive claims" section explaining the auto-claim model. README.md: - `spel_cli::run()` → `spel::run()`. - Writing-Programs example: switch to `execute(…)` pattern, drop `nssa_core::…` imports, update the prelude-contents note. - Account-attributes table: update the `init` row to describe auto-claim. - Makefile example: `--owner-account` → `--owner`, add `--` separator. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 29 +- docs/reference/cli.md | 18 +- docs/reference/macros.md | 9 +- docs/reference/types.md | 29 +- docs/tutorial.md | 379 ++++++++++++--------------- skills/spel/SKILL.md | 28 +- skills/spel/references/cli-ref.md | 16 +- skills/spel/references/gotchas.md | 44 ++-- skills/spel/references/quickstart.md | 85 +++--- 9 files changed, 285 insertions(+), 352 deletions(-) diff --git a/README.md b/README.md index 081078ae..0673f265 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ make build # Build the guest binary (risc0) make idl # Generate IDL from #[lez_program] annotations make deploy # Deploy to sequencer make cli ARGS="--help" # See auto-generated commands -make cli ARGS="-p initialize --owner-account " +make cli ARGS="-p -- initialize --owner " ``` ## Writing Programs @@ -56,8 +56,6 @@ make cli ARGS="-p initialize --owner-account " ```rust #![no_main] -use nssa_core::account::AccountWithMetadata; -use nssa_core::program::AccountPostState; use spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); @@ -70,44 +68,39 @@ mod my_program { #[instruction] pub fn initialize( #[account(init, pda = literal("state"))] - mut state: AccountWithMetadata, + state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, ) -> SpelResult { - // Your logic here - Ok(SpelOutput::states_only(vec![ - AccountPostState::new_claimed(state.account.clone(), Claim::Authorized), - AccountPostState::new(owner.account.clone()), - ])) + // Your logic here — mutate state.account.data if you need to write state. + Ok(SpelOutput::execute(vec![state, owner], vec![])) } #[instruction] pub fn transfer( #[account(mut, pda = literal("state"))] - mut state: AccountWithMetadata, + state: AccountWithMetadata, recipient: AccountWithMetadata, #[account(signer)] sender: AccountWithMetadata, amount: u128, ) -> SpelResult { // Your logic here - Ok(SpelOutput::states_only(vec![ - AccountPostState::new(state.account.clone()), - AccountPostState::new(recipient.account.clone()), - AccountPostState::new(sender.account.clone()), - ])) + Ok(SpelOutput::execute(vec![state, recipient, sender], vec![])) } } ``` -> **Note:** Import everything from `lez_framework::prelude::*` — this provides `AccountWithMetadata`, `AccountPostState`, `LezOutput`, `LezResult`, `LezError`, `BorshSerialize`, `BorshDeserialize`, and more. Do not import from `nssa_core` directly to avoid version conflicts. +> **Note:** Import everything from `spel_framework::prelude::*` — this provides `AccountWithMetadata`, `SpelOutput`, `SpelResult`, `SpelError`, `AccountPostState`, `Claim`, `AutoClaim`, `BorshSerialize`, `BorshDeserialize`, and more. Do not import from `nssa_core` directly to avoid version conflicts. +> +> The `#[lez_program]` macro reads each handler's `#[account(…)]` attributes and generates the correct claim metadata for every entry in the `vec![…]` passed to `SpelOutput::execute(…)` — you never write `AccountPostState::new_claimed(…, Claim::Authorized)` by hand. (That legacy API is still available via the `#[deprecated]` `SpelOutput::states_only` / `with_chained_calls` constructors.) ### Account Attributes | Attribute | Description | |-----------|-------------| | `#[account(mut)]` | Account is writable | -| `#[account(init)]` | Account is being created (use `new_claimed`) | +| `#[account(init)]` | Account is being created; the macro emits the correct `AutoClaim::Claimed(…)` automatically when you return `SpelOutput::execute(…)` | | `#[account(signer)]` | Account must sign the transaction | | `#[account(pda = literal("seed"))]` | PDA derived from a constant string | | `#[account(pda = account("other"))]` | PDA derived from another account's ID | @@ -154,7 +147,7 @@ Every program gets a full CLI for free. The wrapper is just: ```rust #[tokio::main] async fn main() { - spel_cli::run().await; + spel::run().await; } ``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cb84f740..dd5c59c5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -11,7 +11,7 @@ For a guided walkthrough, see the [Tutorial](../tutorial.md). For other referenc ```rust #[tokio::main] async fn main() { - spel_cli::run().await; + spel::run().await; } ``` @@ -210,7 +210,7 @@ Looks up the named account across all instructions in the IDL, finds its PDA see **Seed resolution:** - `const` seeds: resolved from the IDL definition - `arg` seeds: must be provided via `-- ` (parsed through the IDL type of the owning instruction's argument) -- `account` seeds: must be provided via `---account ` +- `account` seeds: must be provided via `-- ` **ProgramId resolution** (in priority order): 1. `--program <64-char-hex>` @@ -269,20 +269,20 @@ spel --program abc123...def pda multisig_vault__ 0a1b2c3d... Execute any instruction defined in the IDL. The CLI auto-generates subcommands from the IDL. ```bash -spel --idl --program -- [-- ...] [---account ...] +spel --idl --program -- [-- ...] [-- ...] ``` With `spel.toml`: ```bash -spel [-- ...] [---account ...] +spel [-- ...] [-- ...] ``` Instruction names are converted from `snake_case` to `kebab-case` in CLI commands (e.g., `create_proposal` → `create-proposal`). **Arguments:** - Instruction args: `-- ` (type-aware parsing from IDL) -- Non-PDA accounts: `---account ` (64 hex chars or base58 string) +- Non-PDA accounts: `-- ` (64 hex chars or base58 string) — the flag is just the account's parameter name, no suffix - PDA accounts: **auto-computed** from seeds — not passed as arguments - Rest (variadic) accounts: optional, comma-separated list of account IDs @@ -314,14 +314,14 @@ spel --idl multisig-idl.json --program multisig.bin -- create \ --create-key 0a1b2c3d4e5f... \ --threshold 2 \ --members "aabb...00,ccdd...00" \ - --creator-account EjR7...base58 + --creator EjR7...base58 # Auto-fill cross-program reference spel --idl treasury-idl.json --program treasury.bin \ --bin-token token.bin -- \ transfer --amount 100 \ - --from-account aabb...00 \ - --to-account ccdd...00 + --from aabb...00 \ + --to ccdd...00 ``` **Example (with spel.toml in the project root):** @@ -331,7 +331,7 @@ spel create \ --create-key 0a1b2c3d4e5f... \ --threshold 2 \ --members "aabb...00,ccdd...00" \ - --creator-account EjR7...base58 + --creator EjR7...base58 ``` --- diff --git a/docs/reference/macros.md b/docs/reference/macros.md index 00a29428..d479c982 100644 --- a/docs/reference/macros.md +++ b/docs/reference/macros.md @@ -76,11 +76,8 @@ mod treasury { depositor: AccountWithMetadata, amount: u128, ) -> SpelResult { - // ... business logic ... - Ok(SpelOutput::states_only(vec![ - AccountPostState::new(vault.account.clone()), - AccountPostState::new(depositor.account.clone()), - ])) + // ... business logic (mutate vault.account.data as needed) ... + Ok(SpelOutput::execute(vec![vault, depositor], vec![])) } } ``` @@ -135,7 +132,7 @@ Applied via `#[account(...)]` on account parameters: | Constraint | Syntax | Description | |------------|--------|-------------| | `signer` | `#[account(signer)]` | Requires `is_authorized == true`. Generates a runtime check before the handler runs. | -| `init` | `#[account(init)]` | Account must be uninitialized (`== Account::default()`). **Implies `mut`**. Used with `AccountPostState::new_claimed()`. | +| `init` | `#[account(init)]` | Account must be uninitialized (`== Account::default()`). **Implies `mut`**. The macro emits an `AutoClaim::Claimed(…)` for the account automatically when you return via `SpelOutput::execute(…)`. | | `mut` | `#[account(mut)]` | Account is writable. Sets `writable: true` in the IDL. | | `owner` | `#[account(owner = EXPR)]` | Account must be owned by the given program ID. The expression should resolve to `[u8; 32]`. | | `pda` | `#[account(pda = SEED)]` | Account address is a PDA derived from the program ID and seed(s). See [PDA Seeds](#pda-seeds) below. Sets the `pda` field in the IDL. | diff --git a/docs/reference/types.md b/docs/reference/types.md index 6373867e..b6136e8d 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -21,25 +21,22 @@ pub struct SpelOutput { | Method | Signature | Description | |--------|-----------|-------------| -| `states_only` | `fn states_only(post_states: Vec) -> Self` | Create output with only post-states and no chained calls. Most common case. | -| `with_chained_calls` | `fn with_chained_calls(post_states: Vec, chained_calls: Vec) -> Self` | Create output with both post-states and chained calls (cross-program invocation). | -| `empty` | `fn empty() -> Self` | Create an empty output (no states, no calls). | +| `execute` | `fn execute>(accounts: I, calls: Vec) -> Self` | **Idiomatic.** Accepts the handler's `AccountWithMetadata` idents directly; the `#[lez_program]` macro rewrites the call to `execute_with_claims(…)` with the correct `AutoClaim` per account derived from its `#[account(…)]` constraints. | +| `empty` | `fn empty() -> Self` | Empty output (no states, no calls). | | `into_parts` | `fn into_parts(self) -> (Vec, Vec)` | Destructure into the tuple form expected by `write_nssa_outputs_with_chained_call`. Used by generated code. | +| `states_only` *(deprecated)* | `fn states_only(post_states: Vec) -> Self` | Legacy constructor — marked `#[deprecated]` in favor of `execute`. Use only when building `AccountPostState` values that `execute` can't express. | +| `with_chained_calls` *(deprecated)* | `fn with_chained_calls(post_states: Vec, chained_calls: Vec) -> Self` | Legacy constructor — marked `#[deprecated]` in favor of `execute`. | ### Example ```rust -// Most instructions: just return updated states -Ok(SpelOutput::states_only(vec![ - AccountPostState::new_claimed(new_account), // init - AccountPostState::new(updated_account), // update -])) - -// Cross-program call -Ok(SpelOutput::with_chained_calls( - vec![AccountPostState::new(state.account.clone())], - vec![chained_call], -)) +// Idiomatic: pass the handler's AccountWithMetadata parameters directly. +// The macro auto-derives the claim from #[account(init/mut/…)] attributes. +// Mutate `acc.account.data` first if the handler writes state. +Ok(SpelOutput::execute(vec![state, owner], vec![])) + +// With a cross-program chained call: +Ok(SpelOutput::execute(vec![state, owner], vec![chained_call])) ``` --- @@ -158,8 +155,8 @@ The following types are re-exported through `spel-framework-core::prelude`: |------|--------|-------------| | `Account` | `nssa_core::account` | Generic account wrapper | | `AccountWithMetadata` | `nssa_core::account` | Account data plus `account_id` and `is_authorized` flag. Primary type used in instruction handlers. | -| `AccountPostState` | `nssa_core::program` | Represents the state of an account after instruction execution. Use `new()` for existing accounts, `new_claimed()` for newly initialized accounts. | -| `ChainedCall` | `nssa_core::program` | Cross-program invocation data. Returned in `SpelOutput::with_chained_calls()`. | +| `AccountPostState` | `nssa_core::program` | Represents the state of an account after instruction execution. Prefer `SpelOutput::execute(vec![accounts], …)` (auto-generated by the macro) over constructing these by hand. Constructors: `new(account)` for unclaimed / updates, `new_claimed(account, Claim)` when you need an explicit claim shape. | +| `ChainedCall` | `nssa_core::program` | Cross-program invocation data. Pass a `Vec` as the second argument to `SpelOutput::execute(accounts, calls)`. | | `PdaSeed` | `nssa_core::program` | A 32-byte PDA seed. Constructed via `PdaSeed::new(bytes)`. | | `ProgramId` | `nssa_core::program` | Type alias for `[u32; 8]`. Identifies a program by its RISC Zero image ID. | diff --git a/docs/tutorial.md b/docs/tutorial.md index 35fed473..1049e586 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -8,14 +8,12 @@ We reference [logos-co/lez-multisig](https://github.com/logos-co/lez-multisig) a - [Prerequisites](#prerequisites) - [Step 1: Scaffold the Project](#step-1-scaffold-the-project) -- [Step 2: Define Your State](#step-2-define-your-state) -- [Step 3: Write Instructions](#step-3-write-instructions) -- [Step 4: Set Up IDL Generation](#step-4-set-up-idl-generation) -- [Step 5: Set Up the CLI Wrapper](#step-5-set-up-the-cli-wrapper) -- [Step 6: Build and Generate IDL](#step-6-build-and-generate-idl) -- [Step 7: Deploy](#step-7-deploy) -- [Step 8: Interact with Your Program](#step-8-interact-with-your-program) -- [Step 9: Register in SPELbook](#step-9-register-in-spelbook) +- [Step 2: Write the Program](#step-2-write-the-program) +- [Step 3: Set Up the CLI Wrapper](#step-3-set-up-the-cli-wrapper) +- [Step 4: Build and Generate IDL](#step-4-build-and-generate-idl) +- [Step 5: Deploy](#step-5-deploy) +- [Step 6: Interact with Your Program](#step-6-interact-with-your-program) +- [Step 7: Register in SPELbook](#step-7-register-in-spelbook) - [Concepts Deep Dive](#concepts-deep-dive) - [How the Macro Works](#how-the-macro-works) - [Account Validation](#account-validation) @@ -60,9 +58,10 @@ This generates: my-counter/ ├── Cargo.toml # Workspace ├── Makefile # build, idl, cli, deploy targets +├── spel.toml # [program] config — spel auto-discovers idl/binary ├── .gitignore ├── README.md -├── my_counter_core/ # Shared types +├── my_counter_core/ # (optional) shared host-side types │ ├── Cargo.toml │ └── src/lib.rs ├── methods/ @@ -79,47 +78,39 @@ my-counter/ └── my_counter_cli.rs # CLI wrapper (three lines) ``` -The scaffold includes a working example with `initialize` and `do_something` instructions. We'll replace these with our counter logic. +The scaffold includes a working example with placeholder `initialize` and `do_something` instructions. We'll replace these with our counter logic. -> **Real-world example:** The [lez-multisig](https://github.com/logos-co/lez-multisig) program follows this exact structure, with a `multisig_core` crate for shared types and a guest binary for the on-chain program. +The `spel.toml` is the reason you'll be able to call `spel initialize …` later without `-i`/`-p` flags — `spel` walks up from the current directory until it finds one, then reads `[program].idl` and `[program].binary`. See the [CLI reference](reference/cli.md#configuration-speltoml) for the full format. + +> **Real-world example:** The [lez-multisig](https://github.com/logos-co/lez-multisig) program follows this structure, with a `multisig_core` crate for genuinely-shared types (an instruction enum consumed by FFI clients) and a guest binary for the on-chain program. --- -## Step 2: Define Your State +## Step 2: Write the Program + +The counter's state and instructions all live in one file: `methods/guest/src/bin/my_counter.rs`. The state struct carries `#[account_type]` so `spel inspect` can later decode it, and every handler returns `SpelOutput::execute(…)` — the macro reads the `#[account(…)]` constraints and generates the correct claim metadata for you. -Edit `my_counter_core/src/lib.rs` to define your counter state: +Replace the scaffold's contents with: ```rust -use serde::{Deserialize, Serialize}; +#![no_main] + +use spel_framework::prelude::*; + +risc0_zkvm::guest::entry!(main); /// The counter state stored on-chain. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +/// +/// `#[account_type]` registers this in the IDL so `spel inspect --type CounterState` +/// can decode raw account bytes into readable JSON. +#[account_type] +#[derive(Debug, Clone, Default, BorshSerialize, BorshDeserialize)] pub struct CounterState { /// The current count value. pub count: u64, /// The owner who can increment. pub owner: [u8; 32], } -``` - -This state struct lives in the `_core` crate so it can be shared between the on-chain guest program and any off-chain tools. It needs to be serializable since it's stored in account data. - -> **Real-world example:** In lez-multisig, the `multisig_core` crate defines the `MultisigState` struct with fields like `threshold`, `members`, and `proposal_index`. - ---- - -## Step 3: Write Instructions - -This is the core of your program. Edit `methods/guest/src/bin/my_counter.rs`: - -```rust -#![no_main] - -use nssa_core::account::AccountWithMetadata; -use nssa_core::program::AccountPostState; -use spel_framework::prelude::*; - -risc0_zkvm::guest::entry!(main); #[lez_program] mod my_counter { @@ -133,160 +124,132 @@ mod my_counter { #[instruction] pub fn initialize( #[account(init, pda = literal("counter"))] - counter: AccountWithMetadata, + mut counter: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, ) -> SpelResult { - // Serialize initial state into the account data - let state = my_counter_core::CounterState { + let state = CounterState { count: 0, owner: *owner.account_id.value(), }; - let data = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { + let bytes = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { message: e.to_string(), })?; + counter.account.data = bytes.try_into().unwrap(); - let mut new_account = counter.account.clone(); - new_account.data = data.try_into().unwrap(); - - Ok(SpelOutput::states_only(vec![ - AccountPostState::new_claimed(new_account), - AccountPostState::new(owner.account.clone()), - ])) + Ok(SpelOutput::execute(vec![counter, owner], vec![])) } - /// Increment the counter by a given amount. - /// - /// Only the owner can increment. The counter account is a PDA - /// derived from the literal seed "counter". + /// Increment the counter by a given amount. Only the owner can increment. #[instruction] pub fn increment( #[account(mut, pda = literal("counter"))] - counter: AccountWithMetadata, + mut counter: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, amount: u64, ) -> SpelResult { - // Deserialize current state - let mut state: my_counter_core::CounterState = - borsh::from_slice(&counter.account.data).map_err(|e| { - SpelError::DeserializationError { - account_index: 0, - message: e.to_string(), - } - })?; - - // Verify the signer is the owner + let data: Vec = counter.account.data.clone().into(); + let mut state: CounterState = borsh::from_slice(&data).map_err(|e| { + SpelError::DeserializationError { + account_index: 0, + message: e.to_string(), + } + })?; + if *owner.account_id.value() != state.owner { return Err(SpelError::Unauthorized { message: "Only the owner can increment".to_string(), }); } - // Increment with overflow check state.count = state.count.checked_add(amount).ok_or(SpelError::Overflow { operation: "counter increment".to_string(), })?; - // Serialize updated state - let data = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { + let bytes = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { message: e.to_string(), })?; - let mut updated = counter.account.clone(); - updated.data = data.try_into().unwrap(); + counter.account.data = bytes.try_into().unwrap(); - Ok(SpelOutput::states_only(vec![ - AccountPostState::new(updated), - AccountPostState::new(owner.account.clone()), - ])) + Ok(SpelOutput::execute(vec![counter, owner], vec![])) } - /// Get the current count value. + /// Get the current count value (read-only). /// - /// This is a read-only instruction — it returns the state unchanged. - /// The count is embedded in the transaction output for off-chain reading. + /// The caller inspects the counter account after the transaction to read the count — + /// see Step 6 for the `spel inspect … --type CounterState` flow. #[instruction] pub fn get_count( #[account(pda = literal("counter"))] counter: AccountWithMetadata, ) -> SpelResult { - let state: my_counter_core::CounterState = - borsh::from_slice(&counter.account.data).map_err(|e| { - SpelError::DeserializationError { - account_index: 0, - message: e.to_string(), - } - })?; - - // Return account unchanged (read-only) - Ok(SpelOutput::states_only(vec![ - AccountPostState::new(counter.account.clone()), - ])) + Ok(SpelOutput::execute(vec![counter], vec![])) } } ``` +A few things to note about this file: + +- **`use spel_framework::prelude::*;`** is the only import. The prelude brings in `AccountWithMetadata`, `SpelResult`, `SpelError`, `SpelOutput`, `Claim`, `AccountPostState`, `AutoClaim`, and the Borsh derives. You don't need any `nssa_core::…` imports. +- **`#[account_type]` must live at file top level**, not inside `mod my_counter { … }`. The IDL generator only scans top-level items for this marker. +- **`mut counter: AccountWithMetadata`** — handlers that write to `counter.account.data` need `mut` on the parameter. Handlers that only read (like `get_count`) don't. +- **`SpelOutput::execute(vec![accounts], vec![chained_calls])`** is the idiomatic return. The macro inspects each account's `#[account(…)]` attributes and generates the correct claim — you never manually construct `AccountPostState::new_claimed(…, Claim::Authorized)` (that API is still available but deprecated, and it's noisier). +- The `my_counter_core/` crate ships empty in this tutorial. It exists for types that need to be *literally shared* across crates (e.g. an external `Instruction` enum consumed by an FFI client generated with `spel-client-gen`) — which our counter doesn't need. + Let's break down what's happening: ### Key concepts -1. **`#[lez_program]`** — wraps your module and generates the guest `main()`, instruction dispatch, and IDL. +1. **`#[lez_program]`** — wraps your module and generates the guest `main()`, instruction dispatch, validation helpers, and an IDL constant. 2. **`#[instruction]`** — marks each function as an on-chain instruction. The function name becomes a CLI subcommand (e.g., `increment` → `spel increment`). -3. **`#[account(init, pda = literal("counter"))]`** — the counter account is a PDA (Program Derived Address) derived from the string `"counter"` and the program ID. The `init` constraint means this account must not already exist. +3. **`#[account(init, pda = literal("counter"))]`** — the counter account is a PDA (Program Derived Address) derived from the string `"counter"` and the program ID. The `init` constraint means this account must not already exist yet, and implies writable. 4. **`#[account(signer)]`** — the owner must sign the transaction. The framework automatically checks `is_authorized` before your handler runs. 5. **`#[account(mut, pda = literal("counter"))]`** — the counter account is writable (its state will change) and is a PDA. -6. **`AccountPostState::new_claimed(account)`** — claims a new account (used with `init`). +6. **`#[account_type]`** — placed on structs/enums stored in account `data`, this registers them in the IDL so `spel inspect --type …` can decode raw bytes. Must live at the **top level of the guest file**, not inside `mod my_counter { … }`. -7. **`AccountPostState::new(account)`** — updates an existing account. +7. **`SpelOutput::execute(vec![accounts], vec![chained_calls])`** — the idiomatic return from a handler. The macro derives the correct claim for each account from its `#[account(…)]` attributes, so you never write `AccountPostState::new_claimed(…, Claim::Authorized)` by hand. > **Real-world example:** The lez-multisig program has instructions like `create` (with `init` + multi-seed PDA), `create_proposal`, and `approve` (with signer checks for members). --- -## Step 4: Set Up IDL Generation - -The scaffold already created `examples/src/bin/generate_idl.rs`. Make sure it points to your program: - -```rust -/// Generate IDL JSON for the my-counter program. -spel_framework::generate_idl!("../methods/guest/src/bin/my_counter.rs"); -``` - -This reads your program source at compile time and generates a `main()` that prints the complete IDL as JSON. The IDL describes all instructions, their accounts, arguments, PDA seeds, and types. - ---- - -## Step 5: Set Up the CLI Wrapper +## Step 3: Set Up the CLI Wrapper The scaffold already created `examples/src/bin/my_counter_cli.rs`: ```rust #[tokio::main] async fn main() { - spel_cli::run().await; + spel::run().await; } ``` -That's it — three lines. The CLI reads the IDL at runtime and auto-generates subcommands for every instruction in your program. +That's it — three lines. The CLI reads the IDL at runtime and auto-generates subcommands for every instruction in your program. (The crate/binary is named `spel`, so the lib module is `spel::`, not `spel_cli::`.) --- -## Step 6: Build and Generate IDL +## Step 4: Build and Generate IDL ```bash # Build the guest binary (compiles for RISC Zero zkVM) make build +``` + +Then generate the IDL. **Use `spel generate-idl`**, not `make idl` — the Makefile target goes through a proc-macro path that does not pick up `#[account_type]` markers, so `spel inspect --type CounterState` would silently have nothing to decode: -# Generate the IDL from your program annotations -make idl +```bash +spel generate-idl methods/guest/src/bin/my_counter.rs > my-counter-idl.json ``` -The `make idl` command runs `cargo run --bin generate_idl` and writes `my-counter-idl.json`. Let's look at what it generates: +> **Why two paths exist:** the scaffold's `make idl` runs a host-side `generate_idl` binary built from a proc macro. The proc macro emits instruction metadata correctly but currently skips file-level `#[account_type]` structs. The `spel generate-idl` CLI subcommand uses a second, fuller generator that includes them. Track [this issue](https://github.com/logos-co/spel/issues) for when the two paths merge; until then, prefer the CLI. + +Let's look at what the generator writes to `my-counter-idl.json`: ```json { @@ -301,18 +264,9 @@ The `make idl` command runs `cargo run --bin generate_idl` and writes `my-counte "writable": true, "signer": false, "init": true, - "pda": { - "seeds": [ - { "kind": "const", "value": "counter" } - ] - } + "pda": { "seeds": [{ "kind": "const", "value": "counter" }] } }, - { - "name": "owner", - "writable": false, - "signer": true, - "init": false - } + { "name": "owner", "writable": false, "signer": true, "init": false } ], "args": [] }, @@ -324,22 +278,11 @@ The `make idl` command runs `cargo run --bin generate_idl` and writes `my-counte "writable": true, "signer": false, "init": false, - "pda": { - "seeds": [ - { "kind": "const", "value": "counter" } - ] - } + "pda": { "seeds": [{ "kind": "const", "value": "counter" }] } }, - { - "name": "owner", - "writable": false, - "signer": true, - "init": false - } + { "name": "owner", "writable": false, "signer": true, "init": false } ], - "args": [ - { "name": "amount", "type": "u64" } - ] + "args": [{ "name": "amount", "type": "u64" }] }, { "name": "get_count", @@ -349,28 +292,39 @@ The `make idl` command runs `cargo run --bin generate_idl` and writes `my-counte "writable": false, "signer": false, "init": false, - "pda": { - "seeds": [ - { "kind": "const", "value": "counter" } - ] - } + "pda": { "seeds": [{ "kind": "const", "value": "counter" }] } } ], "args": [] } - ] + ], + "accounts": [ + { + "name": "CounterState", + "type": { + "kind": "struct", + "fields": [ + { "name": "count", "type": "u64" }, + { "name": "owner", "type": { "array": ["u8", 32] } } + ] + } + } + ], + "errors": [], + "types": [] } ``` Notice: -- The `counter` account has `"pda"` with a `"const"` seed — the CLI will compute this automatically -- The `owner` account has `"signer": true` — the CLI will handle wallet signing -- `init: true` on the first instruction's counter account — the CLI knows this is a new account -- `amount` is the only instruction argument — everything else is an account +- The `counter` account has `"pda"` with a `"const"` seed — the CLI will compute this automatically. +- The `owner` account has `"signer": true` — the CLI will handle wallet signing. +- `init: true` on the first instruction's counter account — the CLI knows this is a new account. +- `amount` is the only instruction argument — everything else is an account. +- `accounts` at the top level lists every `#[account_type]` struct with its field schema. `spel inspect --type ` uses this to decode raw account bytes. --- -## Step 7: Deploy +## Step 5: Deploy First, set up your accounts and deploy the program: @@ -398,26 +352,14 @@ Save the hex ImageID — you'll need it for CLI commands. --- -## Step 8: Interact with Your Program - -### Set up `spel.toml` (optional, recommended) - -The scaffold creates a `spel.toml` in your project root: +## Step 6: Interact with Your Program -```toml -[program] -idl = "my-counter-idl.json" -binary = "methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin" -``` - -With this file in place, `spel` auto-discovers the IDL and binary — you can drop `-i`/`-p` flags and call subcommands directly. All examples below show both variants. +All commands below assume you're inside the project directory, so `spel` picks up `spel.toml` and resolves the IDL and binary automatically. Without `spel.toml` you'd need to pass `-i -p --` before the instruction — see [Without `spel.toml`](#without-speltoml) below. ### See available commands ```bash -spel --help # with spel.toml -# or -spel --idl my-counter-idl.json --help +spel --help ``` Output: @@ -431,72 +373,69 @@ USAGE: COMMANDS: inspect [FILE...] Print ProgramId for ELF binary(ies) - idl Print IDL information - initialize --owner-account - increment --amount --owner-account + generate-idl [PATH] Generate IDL JSON + idl Print the loaded IDL + initialize --owner + increment --amount --owner get-count ``` Notice how the CLI auto-generated commands from your IDL: -- PDA accounts (`counter`) are not listed as arguments — they're computed automatically -- Instruction arguments (`amount`) are typed -- Account arguments (`owner`) expect base58 or hex - -### The `--` separator +- PDA accounts (`counter`) are not listed as arguments — they're computed automatically. +- Instruction arguments (`amount`) are typed. +- Account arguments get a flag named after the account itself (`owner` → `--owner`). Account flags expect base58 or 64-character hex. -When invoking `spel` **without** a `spel.toml`, global options (`--idl`, `--program`, `--dry-run`) must come before a `--` separator, and the instruction plus its `--arg` flags come after: +### Initialize the counter ```bash -spel --idl my-counter-idl.json --program ./my_counter.bin -- \ - increment --amount 5 --owner-account +spel initialize --owner ``` -Without `--`, the first `--amount` would be consumed as a global flag and error out. With a `spel.toml`, no separator is needed because there are no global flags in play. - -### Initialize the counter +The CLI will: +1. Resolve the program binary from `spel.toml` and derive the ProgramId. +2. Compute the `counter` PDA from the seed `"counter"` + ProgramId. +3. Fetch the nonce for the signer account from your wallet. +4. Build, sign, and submit the transaction. +5. Wait for confirmation. -With `spel.toml`: +### Increment the counter ```bash -spel initialize --owner-account +spel increment --amount 5 --owner ``` -Without `spel.toml` (via `make cli`, which forwards `ARGS`): +### Read the count back + +The counter's state lives in the PDA's account data. Compute the PDA address and decode it with `spel inspect --type`: ```bash -make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/my_counter.bin -- \ - initialize --owner-account " +COUNTER_PDA=$(spel pda counter) +spel inspect "$COUNTER_PDA" --type CounterState ``` -The CLI will: -1. Load the program binary (or resolve it from `spel.toml`) to get the ProgramId -2. Compute the `counter` PDA from the seed `"counter"` + ProgramId (and print the seeds it used) -3. Fetch the nonce for the signer account from the wallet -4. Build and sign the transaction -5. Submit to the sequencer -6. Wait for confirmation +Typical output: -### Increment the counter - -```bash -spel increment --amount 5 --owner-account ``` +Account: DzEcGdM7RqkGpG6QtQhoVhMmiSoVrqB4pL3AzZCtoMvZ +Data: 40 bytes +Hex: 0500000000000000cdc32169...b905ded1c169a66aca040a277584bdbf13 -### Pass a raw program ID instead of a binary +{ + "count": "5", + "owner": "cdc32169ea799edca123080eb858b4b905ded1c169a66aca040a277584bdbf13" +} +``` -`--program` accepts three forms: a name from `spel.toml`, a 64-character hex program ID, or a file path to the ELF binary. Using the hex ID skips loading the binary and is faster: +The decode works because `CounterState` is annotated with `#[account_type]` in your program source, which puts its field schema in the IDL. **Be sure to use `spel generate-idl` (not `make idl`) to produce the IDL** — `make idl` uses a proc-macro path that currently skips these types, and `spel inspect --type CounterState` would then fail with "type not found." -```bash -spel --idl my-counter-idl.json --program <64-CHAR-HEX> -- \ - increment --amount 10 --owner-account -``` +> **Note:** If `spel inspect` inside the project complains that `--type` is required even when inspecting an ELF binary, run it from a directory without a `spel.toml` (e.g. `cd /tmp && spel inspect /full/path/to/my_counter.bin`). The binary-vs-account mode selector is currently ambiguous when a spel.toml provides a default IDL. ### Dry run (no submission) `--dry-run` resolves the whole transaction (PDAs, accounts, signer nonces, serialized data) and prints it without submitting: ```bash -spel --dry-run increment --amount 5 --owner-account +spel --dry-run increment --amount 5 --owner ``` Typical text output: @@ -522,14 +461,25 @@ Signers: Dry run complete — not submitted. ``` +The `seeds: […]` line is rendered during PDA resolution and is only shown in dry-run and live-transaction output — not by the standalone `spel pda` subcommand, which prints only the address. + For machine-readable output (e.g. in CI golden tests or `jq` pipelines), use `--dry-run=json`: ```bash -spel --dry-run=json increment --amount 5 --owner-account | jq . +spel --dry-run=json increment --amount 5 --owner | jq . ``` In JSON mode all human preamble is suppressed — only the JSON document goes to stdout. +### Pass a raw program ID instead of a binary + +`--program` accepts three forms: a name from `spel.toml`, a 64-character hex program ID, or a file path to the ELF binary. Using the hex ID skips loading the binary and is faster: + +```bash +spel --idl my-counter-idl.json --program <64-CHAR-HEX> -- \ + increment --amount 10 --owner +``` + ### Compute the counter PDA manually ```bash @@ -537,11 +487,22 @@ spel pda counter # with spel.toml spel --idl my-counter-idl.json --program pda counter ``` -This prints the base58 AccountId of the counter PDA. The output also echoes the seed inputs used for derivation, e.g. `seeds: [program_id, "counter"]` — useful for debugging PDA mismatches across clients. +This prints the base58 AccountId of the counter PDA and nothing else. If you want to see the seed inputs that were used, run the same instruction in `--dry-run` mode (see above). + +### Without `spel.toml` + +When invoking `spel` from a directory without a `spel.toml`, global options (`--idl`, `--program`, `--dry-run`) come **before** a `--` separator; the instruction and its `--arg` flags go after: + +```bash +spel --idl my-counter-idl.json --program ./my_counter.bin -- \ + increment --amount 5 --owner +``` + +Without the `--`, the first `--amount` would be swallowed by the global-flag parser and the command would error out. The `spel.toml`-based invocations above don't need the separator because no global flags are in play. --- -## Step 9: Register in SPELbook +## Step 7: Register in SPELbook TODO: verify — SPELbook registration process is not yet documented in the codebase. @@ -720,31 +681,33 @@ pub fn multi_approve( In the CLI, pass rest accounts as a comma-separated list: ```bash -spel multi-approve --members-account "addr1,addr2,addr3" +spel multi-approve --members "addr1,addr2,addr3" ``` Rest accounts are always optional (0 entries is valid). The macro splits `pre_states` into fixed accounts (before the rest) and the variadic tail. ### Chained Calls -Instructions can trigger calls to other programs by returning `ChainedCall`s: +Instructions can trigger calls to other programs by returning `ChainedCall`s. The second argument to `SpelOutput::execute(…)` is a `Vec`: ```rust #[instruction] pub fn transfer_and_notify( - // ... accounts ... + #[account(mut)] + from: AccountWithMetadata, + #[account(mut)] + to: AccountWithMetadata, + #[account(signer)] + signer: AccountWithMetadata, amount: u64, ) -> SpelResult { - // ... transfer logic ... + // ... transfer logic (mutate from.account.data / to.account.data) ... let chained_call = ChainedCall { // ... target program and instruction data ... }; - Ok(SpelOutput::with_chained_calls( - vec![/* post states */], - vec![chained_call], - )) + Ok(SpelOutput::execute(vec![from, to, signer], vec![chained_call])) } ``` diff --git a/skills/spel/SKILL.md b/skills/spel/SKILL.md index c758999a..7e05f6cb 100644 --- a/skills/spel/SKILL.md +++ b/skills/spel/SKILL.md @@ -7,7 +7,7 @@ description: "Build, deploy, and interact with LEZ on-chain programs using the S SPEL is a Rust framework for building on-chain programs that run on LEZ (the NSSA execution layer). It provides attribute macros (`#[lez_program]`, `#[instruction]`) that generate zkVM guest binaries, instruction dispatch, account validation, IDL, and CLI tooling from annotated Rust modules. -Programs compile to RISC Zero zkVM guests. The framework auto-generates an `Instruction` enum, `main()` dispatch, validation functions, and a full IDL (JSON). The `spel` CLI reads the IDL at runtime to provide a complete CLI for any program — with a `spel.toml` in the project root, flags are optional and instructions can be invoked as bare subcommands (`spel initialize --owner-account …`). +Programs compile to RISC Zero zkVM guests. The framework auto-generates an `Instruction` enum, `main()` dispatch, validation functions, and a full IDL (JSON). The `spel` CLI reads the IDL at runtime to provide a complete CLI for any program — with a `spel.toml` in the project root, flags are optional and instructions can be invoked as bare subcommands (`spel initialize --owner …`). ## References @@ -46,17 +46,19 @@ Read these files as needed: ### Return values ```rust -// New account (init) -AccountPostState::new_claimed(account) - -// Updated existing account -AccountPostState::new(account) - -// Return with no chained calls (most common) -Ok(SpelOutput::states_only(vec![...])) - -// Return with cross-program calls -Ok(SpelOutput::with_chained_calls(vec![...], vec![chained_call])) +// Idiomatic: let the macro derive claims from #[account(…)] attributes. +// Accounts passed to execute() must be AccountWithMetadata idents; mutate +// `account.account.data` first if the handler writes state. +Ok(SpelOutput::execute(vec![state, owner], vec![])) + +// With chained cross-program calls: +Ok(SpelOutput::execute(vec![state, owner], vec![chained_call])) + +// Legacy API (deprecated, still compiles) — explicit AccountPostState values: +// Ok(SpelOutput::states_only(vec![ +// AccountPostState::new_claimed(state.account.clone(), Claim::Authorized), +// AccountPostState::new(owner.account.clone()), +// ])) ``` ### Variable-length accounts @@ -77,7 +79,7 @@ mod my_program { ... } 1. **Never edit IDL JSON by hand** — always regenerate via `generate_idl!` / `make idl`. 2. **PDA accounts are auto-computed** — never pass them as CLI arguments; the CLI derives them from seeds + program ID. -3. **`init` implies `mut`** — do not add both; use `AccountPostState::new_claimed()` for init accounts. +3. **`init` implies `mut`** — do not add both; the macro emits an `AutoClaim::Claimed(Claim::Authorized)` (or `Claim::Pda(..)` for PDAs) automatically when you return `SpelOutput::execute(vec![account, …], vec![])`. 4. **Account parameters must come before instruction arguments** in function signatures. 5. **Return ALL accounts** in `post_states` — every account passed to the instruction must appear in the output (even unchanged ones). 6. **Only the owning program can decrease an account's balance** — this is enforced by the runtime. diff --git a/skills/spel/references/cli-ref.md b/skills/spel/references/cli-ref.md index f37aa4a8..d30c9040 100644 --- a/skills/spel/references/cli-ref.md +++ b/skills/spel/references/cli-ref.md @@ -84,7 +84,7 @@ Pretty-prints the loaded IDL JSON. spel -i -p pda [-- ] ``` -Looks up account in IDL, resolves seeds, prints base58 address plus seed inputs. +Looks up account in IDL, resolves seeds, prints base58 address. (Dry-run / transaction output echoes the seed inputs on separate lines; the standalone `pda` subcommand does not.) ```bash # With spel.toml @@ -116,10 +116,10 @@ spel --program abc...def pda multisig_vault__ 0a1b2c3d... ```bash # With spel.toml -spel [-- ] [---account ] +spel [-- ] [-- ] # Without spel.toml (`--` is REQUIRED when mixing global flags with instruction flags) -spel -i -p -- [-- ] [---account ] +spel -i -p -- [-- ] [-- ] ``` - Instruction names: `snake_case` → `kebab-case` (`create_proposal` → `create-proposal`) @@ -130,21 +130,21 @@ spel -i -p -- [-- ] [---ac ```bash # Execute instruction (with spel.toml) spel create --create-key 0a1b... --threshold 2 \ - --members "aa...00,bb...00" --creator-account EjR7... + --members "aa...00,bb...00" --creator EjR7... # Same, without spel.toml spel -i idl.json -p prog.bin -- create --create-key 0a1b... --threshold 2 \ - --members "aa...00,bb...00" --creator-account EjR7... + --members "aa...00,bb...00" --creator EjR7... # Dry run (text, default) -spel --dry-run approve --proposal-id 5 --member-account cc...00 +spel --dry-run approve --proposal-id 5 --member cc...00 # Dry run (JSON — pipe through jq in CI) -spel --dry-run=json approve --proposal-id 5 --member-account cc...00 | jq . +spel --dry-run=json approve --proposal-id 5 --member cc...00 | jq . # Cross-program binary reference spel -i treasury-idl.json -p treasury.bin --bin-token token.bin -- \ - transfer --amount 100 --from-account aa...00 + transfer --amount 100 --from aa...00 --to bb...00 # Per-instruction help spel --help diff --git a/skills/spel/references/gotchas.md b/skills/spel/references/gotchas.md index b6d8e07a..faf9cdf8 100644 --- a/skills/spel/references/gotchas.md +++ b/skills/spel/references/gotchas.md @@ -6,42 +6,28 @@ Hard-won lessons from building SPEL programs. Read this before writing or debugg ## Account Handling -### Return ALL accounts in post_states +### Return ALL accounts in the `execute(vec![…])` list -Every account passed to an instruction must appear in the `post_states` vector, even if unchanged. Forgetting an account causes a runtime error. +Every account passed to a handler must appear in the vector you return via `SpelOutput::execute(…)` — even if you didn't mutate it. Dropping an account from the list is a runtime error. ```rust -// WRONG — forgot to return owner -Ok(SpelOutput::states_only(vec![ - AccountPostState::new(updated_state), -])) - -// RIGHT — return all accounts -Ok(SpelOutput::states_only(vec![ - AccountPostState::new(updated_state), - AccountPostState::new(owner.account.clone()), -])) -``` - -### new_claimed vs new +// WRONG — forgot to include owner +Ok(SpelOutput::execute(vec![state], vec![])) -- `AccountPostState::new_claimed(account)` — for `init` accounts only (claims a new account) -- `AccountPostState::new(account)` — for existing accounts (updates) +// RIGHT — every handler parameter (except `Vec` rest lists, +// which you extend into the vec) must appear once. +Ok(SpelOutput::execute(vec![state, owner], vec![])) +``` -Using `new()` on an init account or `new_claimed()` on an existing account will fail at runtime. +### Let the macro derive claims — don't write `AccountPostState` by hand -### new_claimed_if_default() pattern +`SpelOutput::execute(vec![a, b, c], vec![])` passes each `AccountWithMetadata` ident through. The `#[lez_program]` macro reads each parameter's `#[account(init/mut/…)]` constraints and emits the correct `AutoClaim` automatically: -When an account might or might not already exist, use the conditional pattern: +- `#[account(init, …)]` → `AutoClaim::Claimed(Claim::Authorized)` for non-PDA, `Claim::Pda(…)` for PDAs. +- `#[account(mut, …)]` or `#[account(signer)]` without `init` → `AutoClaim::None`. +- Read-only accounts → `AutoClaim::None`. -```rust -// Claims if account is default (uninitialized), updates otherwise -if account.account == Account::default() { - AccountPostState::new_claimed(account) -} else { - AccountPostState::new(account) -} -``` +The legacy `SpelOutput::states_only(…)` / `SpelOutput::with_chained_calls(…)` constructors plus hand-built `AccountPostState::new_claimed(acc, Claim::Authorized)` / `AccountPostState::new(acc)` still compile but carry a `#[deprecated]` note. Use them only when you need a shape `execute` can't produce (which is rare). ### Account ownership rules @@ -139,7 +125,7 @@ The hex form replaces the deprecated `--program-id `. The 64-char hex comes Without a `spel.toml`, global options (`--idl`, `--program`, `--dry-run`) come before a `--` separator; the instruction and its `--arg` flags come after. Without `--`, the first instruction `--flag` is swallowed by the global parser and the command errors out. ```bash -spel -i idl.json -p prog.bin -- increment --amount 5 --owner-account +spel -i idl.json -p prog.bin -- increment --amount 5 --owner ``` With `spel.toml` there are no global flags in play, so `--` is not needed. diff --git a/skills/spel/references/quickstart.md b/skills/spel/references/quickstart.md index 0d23b25a..f10be024 100644 --- a/skills/spel/references/quickstart.md +++ b/skills/spel/references/quickstart.md @@ -28,19 +28,7 @@ my-program/ ## 2. Define State -Edit `my_program_core/src/lib.rs`: - -```rust -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MyState { - pub value: u64, - pub owner: [u8; 32], -} -``` - -State structs live in the `_core` crate so they can be shared between on-chain and off-chain code. +Put state structs in `methods/guest/src/bin/my_program.rs` directly, annotated with `#[account_type]` at file top level — see the next step. The `_core` crate is optional and only needed when a type must be consumed by off-chain code (e.g. an external `Instruction` enum for an FFI client). ## 3. Write Instructions @@ -49,12 +37,19 @@ Edit `methods/guest/src/bin/my_program.rs`: ```rust #![no_main] -use nssa_core::account::AccountWithMetadata; -use nssa_core::program::AccountPostState; use spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); +/// State stored on-chain. `#[account_type]` MUST live at file top-level +/// (not inside the #[lez_program] module) so the IDL generator picks it up. +#[account_type] +#[derive(Debug, Clone, Default, BorshSerialize, BorshDeserialize)] +pub struct MyState { + pub value: u64, + pub owner: [u8; 32], +} + #[lez_program] mod my_program { #[allow(unused_imports)] @@ -63,37 +58,34 @@ mod my_program { #[instruction] pub fn initialize( #[account(init, pda = literal("state"))] - state: AccountWithMetadata, + mut state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, ) -> SpelResult { - let data = borsh::to_vec(&my_program_core::MyState { + let data = borsh::to_vec(&MyState { value: 0, owner: *owner.account_id.value(), - }).map_err(|e| SpelError::SerializationError { message: e.to_string() })?; - - let mut new_account = state.account.clone(); - new_account.data = data.try_into().unwrap(); + }) + .map_err(|e| SpelError::SerializationError { message: e.to_string() })?; + state.account.data = data.try_into().unwrap(); - Ok(SpelOutput::states_only(vec![ - AccountPostState::new_claimed(new_account), - AccountPostState::new(owner.account.clone()), - ])) + Ok(SpelOutput::execute(vec![state, owner], vec![])) } #[instruction] pub fn update( #[account(mut, pda = literal("state"))] - state: AccountWithMetadata, + mut state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, new_value: u64, ) -> SpelResult { - let mut current: my_program_core::MyState = - borsh::from_slice(&state.account.data) - .map_err(|e| SpelError::DeserializationError { - account_index: 0, message: e.to_string(), - })?; + let data: Vec = state.account.data.clone().into(); + let mut current: MyState = borsh::from_slice(&data) + .map_err(|e| SpelError::DeserializationError { + account_index: 0, + message: e.to_string(), + })?; if *owner.account_id.value() != current.owner { return Err(SpelError::Unauthorized { @@ -104,17 +96,15 @@ mod my_program { current.value = new_value; let data = borsh::to_vec(¤t) .map_err(|e| SpelError::SerializationError { message: e.to_string() })?; - let mut updated = state.account.clone(); - updated.data = data.try_into().unwrap(); + state.account.data = data.try_into().unwrap(); - Ok(SpelOutput::states_only(vec![ - AccountPostState::new(updated), - AccountPostState::new(owner.account.clone()), - ])) + Ok(SpelOutput::execute(vec![state, owner], vec![])) } } ``` +The `#[lez_program]` macro reads the `#[account(…)]` attributes on each handler's parameters and generates the correct `AutoClaim` for every entry in the `vec![…]` you pass to `SpelOutput::execute(…)`. You never construct `AccountPostState` values by hand. + ## 4. Set Up IDL Generator `examples/src/bin/generate_idl.rs` (scaffold creates this): @@ -132,7 +122,7 @@ Path is relative to `CARGO_MANIFEST_DIR` (the `examples/` crate). ```rust #[tokio::main] async fn main() { - spel_cli::run().await; + spel::run().await; } ``` @@ -167,27 +157,32 @@ With the scaffold-generated `spel.toml` in the project root, `spel` discovers th spel --help # Initialize (PDA accounts auto-computed, not passed as args) -spel initialize --owner-account +spel initialize --owner # Update with argument -spel update --new-value 42 --owner-account +spel update --new-value 42 --owner # Use a raw 64-char hex program ID to skip binary loading -spel --program <64-CHAR-HEX> -- update --new-value 100 --owner-account +spel --program <64-CHAR-HEX> -- update --new-value 100 --owner # Dry run (text-default; add =json for machine-readable output) -spel --dry-run update --new-value 5 --owner-account -spel --dry-run=json update --new-value 5 --owner-account | jq . +spel --dry-run update --new-value 5 --owner +spel --dry-run=json update --new-value 5 --owner | jq . -# Compute PDA manually — output echoes seed inputs, e.g. `seeds: [program_id, "state"]` +# Compute PDA manually — prints base58 address only. +# (Dry-run / transaction output additionally echoes `seeds: [program_id, "state"]`.) spel pda state + +# Decode a stored account's data via the IDL (requires #[account_type] on the struct +# AND an IDL generated with `spel generate-idl`, not `make idl`) +spel inspect "$(spel pda state)" --type MyState ``` When running without a `spel.toml`, pass `--idl`/`--program` before a `--` separator: ```bash spel -i my-program-idl.json -p methods/guest/target/.../my_program.bin -- \ - update --new-value 42 --owner-account + update --new-value 42 --owner ``` ## 10. Generate Client Code (optional)