diff --git a/README.md b/README.md index 8ceb8e46..0673f265 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ -# spel-framework +# SPEL — Smart Program Execution Layer [![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 SPEL 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 @@ spel 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/ @@ -41,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 @@ -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 spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); @@ -67,11 +72,8 @@ mod my_program { #[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] @@ -84,21 +86,21 @@ mod my_program { 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 `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 | @@ -145,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; } ``` @@ -156,7 +158,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 ### Account Types @@ -205,15 +207,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 @@ -233,6 +235,9 @@ spel inspect --idl my_program-idl.json --type VaultState # Same, but supply raw borsh bytes directly instead of fetching from the network spel inspect --idl my_program-idl.json --type VaultState --data +# Inspect with raw data (offline, no sequencer needed) +lez-cli inspect --data --idl program-idl.json + # Show available commands spel --idl program-idl.json --help @@ -344,4 +349,4 @@ spel inspect 0000...0000 \ ## License -MIT +Dual-licensed under [MIT](LICENSE-MIT) and [Apache-2.0](LICENSE-APACHE-v2). diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 00000000..4eb742fa --- /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: `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) — `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 new file mode 100644 index 00000000..dd5c59c5 --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,449 @@ +# CLI + +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). + +--- + +## Quick Start + +```rust +#[tokio::main] +async fn main() { + spel::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 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 SPEL project. This is the command that creates everything described below — "scaffolding" and `init` refer to the same operation. + +```bash +spel init [--lez-tag ] [--spel-tag ] [--lez-rev ] [--spel-rev ] +``` + +**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 +- `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 +spel 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` + +Two modes — the one you get depends on whether `--idl`/`--type` are set. + +### Mode 1: Print ProgramId for ELF binaries + +```bash +spel 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... +``` + +- **Decimal**: comma-separated `[u32; 8]` values +- **Hex**: comma-separated hex `[u32; 8]` values +- **ImageID hex bytes**: 64-character hex string (little-endian byte representation). This is the value to pass to `--program `. + +**Example:** + +```bash +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 +``` + +--- + +## `idl` (command) + +Print the loaded IDL as pretty-printed JSON. + +```bash +spel --idl idl +``` + +With `spel.toml`: + +```bash +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 +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 `-- ` (parsed through the IDL type of the owning instruction's argument) +- `account` seeds: must be provided via `-- ` + +**ProgramId resolution** (in priority order): +1. `--program <64-char-hex>` +2. `--program ` → resolved binary loaded +3. `--program ` → binary loaded + +**Example:** + +```bash +# Simple PDA with only const seeds +spel --idl my_program-idl.json --program abc123...def pda counter + +# PDA with arg seed (with spel.toml set up) +spel pda multisig_state --create-key 0a1b2c... + +# List available PDAs +spel --idl 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 +spel --program <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 +spel --program abc123...def pda my_state_name + +# Multiple seeds +spel --program abc123...def pda multisig_vault__ 0a1b2c3d... +``` + +--- + +## Instruction Execution + +Execute any instruction defined in the IDL. The CLI auto-generates subcommands from the IDL. + +```bash +spel --idl --program -- [-- ...] [-- ...] +``` + +With `spel.toml`: + +```bash +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: `-- ` (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 + +**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 (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 +8. Poll for confirmation + +**Per-instruction help:** + +```bash +spel --help +``` + +Shows accounts (with flags like `[mut, signer, init]`), PDA status, and argument types. + +**Example (no spel.toml):** + +```bash +# Execute a create instruction +spel --idl multisig-idl.json --program multisig.bin -- create \ + --create-key 0a1b2c3d4e5f... \ + --threshold 2 \ + --members "aabb...00,ccdd...00" \ + --creator EjR7...base58 + +# Auto-fill cross-program reference +spel --idl treasury-idl.json --program treasury.bin \ + --bin-token token.bin -- \ + transfer --amount 100 \ + --from aabb...00 \ + --to ccdd...00 +``` + +**Example (with spel.toml in the project root):** + +```bash +spel create \ + --create-key 0a1b2c3d4e5f... \ + --threshold 2 \ + --members "aabb...00,ccdd...00" \ + --creator 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 + +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 (spel-cli internals) + +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..., ...] +``` + +**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..e0c686a7 --- /dev/null +++ b/docs/reference/client-gen.md @@ -0,0 +1,241 @@ +# Client Code Generation + +`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). + +--- + +## spel-client-gen CLI Usage + +```bash +spel-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 +spel-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 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: SpelIdl = 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 +spel-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..12f8d237 --- /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 `SpelError` variants have fixed codes (see [Types — SpelError](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 `spel-framework-core/src/idl.rs` and `spel-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 `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. + +--- + +## 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..d479c982 --- /dev/null +++ b/docs/reference/macros.md @@ -0,0 +1,273 @@ +# 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:** `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. + +### Syntax + +```rust +#[lez_program] +mod my_program { + #[instruction] + pub fn my_instruction(/* ... */) -> SpelResult { /* ... */ } +} +``` + +With external instruction enum: + +```rust +#[lez_program(instruction = "my_crate::Instruction")] +mod my_program { + #[instruction] + pub fn my_instruction(/* ... */) -> SpelResult { /* ... */ } +} +``` + +### 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 `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 `SpelIdl` 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 spel_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, + ) -> SpelResult { + // ... business logic (mutate vault.account.data as needed) ... + Ok(SpelOutput::execute(vec![vault, depositor], vec![])) + } +} +``` + +This generates: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Instruction { + Deposit { amount: u128 }, +} +``` + +--- + +## `#[instruction]` + +**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. + +### 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, +) -> SpelResult { + // ... +} +``` + +- **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`. + +### 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`**. 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. | +| `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:** `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 +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. + +### 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 +spel_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<(), SpelError> +``` + +**Checks performed (in order):** + +1. **Signer checks** — for each `#[account(signer)]`: + ```rust + if !accounts[idx].is_authorized { + return Err(SpelError::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(SpelError::AccountAlreadyInitialized { + account_index: idx, + }); + } + ``` + +If an instruction has no `signer` or `init` constraints, no validation function is generated. + +--- + +### Validation Helpers + +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<(), 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<(), SpelError>` | 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..b6136e8d --- /dev/null +++ b/docs/reference/types.md @@ -0,0 +1,181 @@ +# Types + +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). + +--- + +## `SpelOutput` + +Return value from instruction handlers. Contains post-states and optional chained calls. + +```rust +pub struct SpelOutput { + pub post_states: Vec, + pub chained_calls: Vec, +} +``` + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `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 +// 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])) +``` + +--- + +## `SpelResult` + +Type alias for instruction handler return types: + +```rust +pub type SpelResult = Result; +``` + +All `#[instruction]` functions must return `SpelResult`. + +--- + +## `SpelError` + +Structured error type for LEZ programs. Borsh-serializable for on-chain representation. + +```rust +#[derive(Error, Debug, BorshSerialize, BorshDeserialize)] +pub enum SpelError { /* ... */ } +``` + +### 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(SpelError::InsufficientBalance { + available: balance, + requested: amount, + }); +} + +// Using custom errors +return Err(SpelError::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 `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. 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. | + +--- + +## Prelude + +Import the prelude for convenient access to common types: + +```rust +use spel_framework::prelude::*; +``` + +This imports: +- `lez_program` (macro) +- `instruction` (macro) +- `SpelOutput` +- `SpelError`, `SpelResult` +- `AccountConstraint` +- `Account`, `AccountWithMetadata` +- `AccountPostState`, `ChainedCall`, `PdaSeed`, `ProgramId` +- `BorshSerialize`, `BorshDeserialize` diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 00000000..1049e586 --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,785 @@ +# 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: 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) + - [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 +- **spel** installed: + +```bash +# From the SPEL repo +cargo install --path spel-cli # installs as the `spel` binary +``` + +--- + +## Step 1: Scaffold the Project + +Use `spel init` to create a new project: + +```bash +spel init my-counter +cd my-counter +``` + +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/ # (optional) shared host-side 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 placeholder `initialize` and `do_something` instructions. We'll replace these with our counter logic. + +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: 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. + +Replace the scaffold's contents with: + +```rust +#![no_main] + +use spel_framework::prelude::*; + +risc0_zkvm::guest::entry!(main); + +/// The counter state stored on-chain. +/// +/// `#[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], +} + +#[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"))] + mut counter: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + ) -> SpelResult { + let state = CounterState { + count: 0, + owner: *owner.account_id.value(), + }; + let bytes = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { + message: e.to_string(), + })?; + counter.account.data = bytes.try_into().unwrap(); + + Ok(SpelOutput::execute(vec![counter, owner], vec![])) + } + + /// Increment the counter by a given amount. Only the owner can increment. + #[instruction] + pub fn increment( + #[account(mut, pda = literal("counter"))] + mut counter: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + amount: u64, + ) -> SpelResult { + 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(), + }); + } + + state.count = state.count.checked_add(amount).ok_or(SpelError::Overflow { + operation: "counter increment".to_string(), + })?; + + let bytes = borsh::to_vec(&state).map_err(|e| SpelError::SerializationError { + message: e.to_string(), + })?; + counter.account.data = bytes.try_into().unwrap(); + + Ok(SpelOutput::execute(vec![counter, owner], vec![])) + } + + /// Get the current count value (read-only). + /// + /// 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 { + 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, 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 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. **`#[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. **`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 3: Set Up the CLI Wrapper + +The scaffold already created `examples/src/bin/my_counter_cli.rs`: + +```rust +#[tokio::main] +async fn main() { + 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. (The crate/binary is named `spel`, so the lib module is `spel::`, not `spel_cli::`.) + +--- + +## 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: + +```bash +spel generate-idl methods/guest/src/bin/my_counter.rs > my-counter-idl.json +``` + +> **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 +{ + "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": [] + } + ], + "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. +- `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 5: 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 6: Interact with Your Program + +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 +``` + +Output: + +``` +🔧 my_counter v0.1.0 — IDL-driven CLI + +USAGE: + spel [ARGS] (with spel.toml) + spel [OPTIONS] -- [ARGS] (without spel.toml) + +COMMANDS: + inspect [FILE...] Print ProgramId for ELF binary(ies) + 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 get a flag named after the account itself (`owner` → `--owner`). Account flags expect base58 or 64-character hex. + +### Initialize the counter + +```bash +spel initialize --owner +``` + +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. + +### Increment the counter + +```bash +spel increment --amount 5 --owner +``` + +### 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 +COUNTER_PDA=$(spel pda counter) +spel inspect "$COUNTER_PDA" --type CounterState +``` + +Typical output: + +``` +Account: DzEcGdM7RqkGpG6QtQhoVhMmiSoVrqB4pL3AzZCtoMvZ +Data: 40 bytes +Hex: 0500000000000000cdc32169...b905ded1c169a66aca040a277584bdbf13 + +{ + "count": "5", + "owner": "cdc32169ea799edca123080eb858b4b905ded1c169a66aca040a277584bdbf13" +} +``` + +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." + +> **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 +``` + +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. +``` + +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 | 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 +spel pda counter # with spel.toml +spel --idl my-counter-idl.json --program pda counter +``` + +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 7: 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<(), SpelError> { + // init check: counter must be default + if accounts[0].account != Account::default() { + return Err(SpelError::AccountAlreadyInitialized { account_index: 0 }); + } + // signer check: owner must be authorized + if !accounts[1].is_authorized { + return Err(SpelError::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() -> SpelIdl { ... } +``` + +### Account Validation + +The framework generates automatic validation checks that run before your handler: + +| Attribute | Check | Error | +|-----------|-------|-------| +| `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. + +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 `spel-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, +) -> SpelResult { + // 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 +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. The second argument to `SpelOutput::execute(…)` is a `Vec`: + +```rust +#[instruction] +pub fn transfer_and_notify( + #[account(mut)] + from: AccountWithMetadata, + #[account(mut)] + to: AccountWithMetadata, + #[account(signer)] + signer: AccountWithMetadata, + amount: u64, +) -> SpelResult { + // ... transfer logic (mutate from.account.data / to.account.data) ... + + let chained_call = ChainedCall { + // ... target program and instruction data ... + }; + + Ok(SpelOutput::execute(vec![from, to, signer], vec![chained_call])) +} +``` + +### Client Code Generation + +For integrating LEZ programs into applications (e.g., a C++/Qt desktop app), use `spel-client-gen` to generate typed bindings: + +```bash +spel-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/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 `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 +#[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()); + } +} +``` diff --git a/skills/spel/SKILL.md b/skills/spel/SKILL.md new file mode 100644 index 00000000..7e05f6cb --- /dev/null +++ b/skills/spel/SKILL.md @@ -0,0 +1,89 @@ +--- +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 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 `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 + +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 `spel` and `spel-client-gen`. Read when constructing CLI commands or checking flag names. + +## Core Workflow + +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** — 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 + +### 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 +// 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 + +```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; 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. +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..d30c9040 --- /dev/null +++ b/skills/spel/references/cli-ref.md @@ -0,0 +1,226 @@ +# CLI Reference + +Condensed cheatsheet for `spel` and `spel-client-gen`. + +--- + +## 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 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 ` | + +--- + +## Commands + +### init — Scaffold New Project + +```bash +spel init +``` + +No `--idl` required. Creates full workspace with Makefile, core crate, guest binary, IDL generator, and CLI wrapper. + +### inspect — Print ProgramId + +```bash +spel 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... ← pass to `--program ` +``` + +### idl — Print IDL + +```bash +spel -i idl +``` + +Pretty-prints the loaded IDL JSON. + +### pda (IDL mode) — Compute PDA from IDL Seeds + +```bash +spel -i -p pda [-- ] +``` + +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 +spel pda counter +spel pda multisig_state --create-key 0a1b2c... + +# 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 +spel -i idl.json pda +``` + +### pda (raw mode) — Compute PDA Without IDL + +```bash +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 +spel --program abc...def pda my_state +spel --program abc...def pda multisig_vault__ 0a1b2c3d... +``` + +### Instruction Execution + +```bash +# With spel.toml +spel [-- ] [-- ] + +# Without spel.toml (`--` is REQUIRED when mixing global flags with instruction flags) +spel -i -p -- [-- ] [-- ] +``` + +- 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 (with spel.toml) +spel create --create-key 0a1b... --threshold 2 \ + --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 EjR7... + +# Dry run (text, default) +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 cc...00 | jq . + +# Cross-program binary reference +spel -i treasury-idl.json -p treasury.bin --bin-token token.bin -- \ + transfer --amount 100 --from aa...00 --to bb...00 + +# Per-instruction help +spel --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` | + +--- + +## spel-client-gen + +Generate typed Rust client + C FFI + C header from IDL: + +```bash +spel-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..faf9cdf8 --- /dev/null +++ b/skills/spel/references/gotchas.md @@ -0,0 +1,173 @@ +# Gotchas and Common Mistakes + +Hard-won lessons from building SPEL programs. Read this before writing or debugging. + +--- + +## Account Handling + +### Return ALL accounts in the `execute(vec![…])` list + +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 include owner +Ok(SpelOutput::execute(vec![state], vec![])) + +// RIGHT — every handler parameter (except `Vec` rest lists, +// which you extend into the vec) must appear once. +Ok(SpelOutput::execute(vec![state, owner], vec![])) +``` + +### Let the macro derive claims — don't write `AccountPostState` by hand + +`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: + +- `#[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`. + +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 + +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 `spel 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) -> SpelResult { ... } + +// RIGHT — accounts first, then arguments +#[instruction] +pub fn good(#[account(signer)] user: AccountWithMetadata, amount: u64) -> SpelResult { ... } +``` + +### 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` accepts name, hex, or file path + +`--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 +``` + +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 + +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..f10be024 --- /dev/null +++ b/skills/spel/references/quickstart.md @@ -0,0 +1,221 @@ +# Quickstart: Scaffold to Deploy + +Full workflow for creating, building, deploying, and interacting with a LEZ program using SPEL. + +--- + +## 1. Scaffold + +```bash +spel init my-program +cd my-program +``` + +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/ +│ ├── generate_idl.rs # IDL generator (one-liner macro) +│ └── my_program_cli.rs # CLI wrapper (three lines) +└── methods/build.rs +``` + +## 2. Define State + +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 + +Edit `methods/guest/src/bin/my_program.rs`: + +```rust +#![no_main] + +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)] + use super::*; + + #[instruction] + pub fn initialize( + #[account(init, pda = literal("state"))] + mut state: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + ) -> SpelResult { + let data = borsh::to_vec(&MyState { + value: 0, + owner: *owner.account_id.value(), + }) + .map_err(|e| SpelError::SerializationError { message: e.to_string() })?; + state.account.data = data.try_into().unwrap(); + + Ok(SpelOutput::execute(vec![state, owner], vec![])) + } + + #[instruction] + pub fn update( + #[account(mut, pda = literal("state"))] + mut state: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + new_value: u64, + ) -> SpelResult { + 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 { + message: "Only the owner can update".to_string(), + }); + } + + current.value = new_value; + let data = borsh::to_vec(¤t) + .map_err(|e| SpelError::SerializationError { message: e.to_string() })?; + state.account.data = data.try_into().unwrap(); + + 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): + +```rust +spel_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() { + spel::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 + +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 +spel --help + +# Initialize (PDA accounts auto-computed, not passed as args) +spel initialize --owner + +# Update with argument +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 + +# Dry run (text-default; add =json for machine-readable output) +spel --dry-run update --new-value 5 --owner +spel --dry-run=json update --new-value 5 --owner | jq . + +# 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 +``` + +## 10. Generate Client Code (optional) + +```bash +spel-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 |