* feat(cli): add witness exchange for multi-signature transactions Some instructions need signatures from accounts whose keys live on different machines. The CLI could only sign with locally held keys, so such transactions were impossible to build. This adds an offline partial-transaction flow. New pieces: - blob.rs: partial-transaction file format v1. JSON envelope over the borsh Message plus collected witnesses. The signers list is the order contract: it matches the nonce order in the message and dictates witness order at submit. Verification checks each signature over the stored message bytes and checks that each pubkey derives the claimed account id. - --co-signer <id> (repeatable) and --export <file> on instruction calls. Export fetches nonces for all signers including co-signers, builds the message once, signs with wallet-held keys and writes the blob instead of submitting. - spel sign <file>: verifies existing witnesses, shows the embedded summary next to fields decoded from the message bytes, then signs for any required signer whose key the local wallet holds. - spel submit <file>: assembles the witness set in signers order and submits through the existing sequencer client. The dry-run text renderer now returns a string so the export path can embed the same summary a dry run prints. Verified on a local LEZ v0.1.2 stack with two wallets: export by one, sign by the other, submit passed signature and nonce validation and failed only at the expected Unknown program for an undeployed id. * test(cli): cover witness exchange, document it in the README Extract two pure fns so the logic is testable without a wallet or process exit: TxBlob::witness_set() assembles the final witness set in signers order, and detect_signable() filters missing signers through an injected key lookup. Tests added: - witness order assembly, constructed so that map iteration order disagrees with the signers order and a wrong implementation fails - missing witness and undecodable signer id error paths - sign auto-detect filtering with a fake key lookup - binary tests for the argument parser: missing flag values, --export given twice, --export with --dry-run in both orders Export now prints a building message instead of claiming submission, and spel sign pairs each nonce with its signer in the decoded view. Add a multi-signature transactions section to the README CLI usage. * fix(cli): sign witness exchange over the v0.2.0 message hash Rebasing witness exchange onto the LEZ v0.2.0 upgrade (#238) left its own signing path on the old preimage. v0.2.0 signs and verifies the 32-byte Message::hash(), not the raw borsh message bytes, and the sequencer runs WitnessSet::is_valid_for over that hash at submit. Signing the raw bytes here builds blobs whose witnesses fail validation. Changes: - export and spel sign now sign Message::hash() instead of the serialized message bytes - verify_witnesses checks each signature against the same hash - adopt the renamed v0.2.0 API: LeeTransaction on submit and WalletCore::get_account_public_signing_key for the key lookup - test helpers and the tampered-message test go through a decodable Message so the signature check is what rejects a tampered blob * feat(cli): show instruction data in the sign prompt The co-signer prompt decoded program id, account ids and nonces from the message bytes but not the instruction data, the operative content of the transaction. A malicious exporter could show a benign summary over a different payload and the co-signer could not notice even in principle. The prompt now prints the raw instruction data as hex in the decoded section, IDL-based decoding into named args stays the planned upgrade. An integration test runs the real binary against a fully signed blob and pins the decode section, instruction data included. Docs corrected alongside: signatures are made over the 32-byte hash of the message bytes, not the bytes themselves, at all four sites that claimed otherwise, and signing is local so staleness surfaces at submission, not at signing. The trust-model note now reflects that a co-signer reads the operative content from the bytes, not from the exporter's summary. * fix(cli): reject unknown flags among instruction arguments Flags after the -- separator were collected into the argument map and silently ignored when nothing consumed them. A global flag like --export misplaced there fell through to a live submission, the opposite of what the caller asked for. parse_instruction_args now validates every key against the instruction's vocabulary, its IDL args and its non-pda accounts, and exits with a pointer at the global position before any transaction is built. Unit tests pin the vocabulary rules, a binary-level test pins the refusal and that no blob is written, the README notes the flag placement. Also settles the let_underscore_must_use lint in generate_idl so this branch commits without the hook skip. * fix(cli): migrate witness exchange to the LEZ v0.2.4 WalletCore API exchange.rs landed after the v0.2.4 migration (#256) was written, so the rebase merged cleanly but left this file on the pre-v0.2.4 API. Apply the same three changes #256 made in tx.rs: WalletCore::from_env() -> .await wallet_core.sequencer_client -> wallet_core.helm_owned() TxPoller::new(config, sequencer_client) -> wallet_core.poller_helm() sign_command becomes async to await from_env(); its only caller in run() is already async. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Václav Pavlín <vaclav.pavlin@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
spel-framework
Developer framework for building SPEL programs — inspired by Anchor for Solana.
Write your program logic with proc macros. Get IDL generation, a full CLI with TX submission, and project scaffolding for free.
Quick Start
Scaffold a new project
cargo install --path spel-cli # installs as "spel"
spel init my-program
cd my-program
This generates a complete project:
my-program/
├── Cargo.toml # Workspace
├── Makefile # build, idl, cli, deploy, inspect, setup
├── README.md
├── my_program_core/ # Shared types (guest + host)
│ └── src/lib.rs
├── methods/
│ └── guest/ # RISC Zero guest (runs on-chain)
│ └── src/bin/my_program.rs
└── examples/
└── src/bin/
├── generate_idl.rs # One-liner IDL generator
└── my_program_cli.rs # Three-line CLI wrapper
Build → Deploy → Transact
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 <binary> initialize --owner-account <BASE58>"
Writing Programs
#![no_main]
use nssa_core::account::AccountWithMetadata;
use nssa_core::program::AccountPostState;
use spel_framework::prelude::*;
risc0_zkvm::guest::entry!(main);
#[lez_program]
mod my_program {
#[allow(unused_imports)]
use super::*;
#[instruction]
pub fn initialize(
#[account(init, pda = literal("state"))]
state: AccountWithMetadata,
#[account(signer)]
owner: AccountWithMetadata,
) -> SpelResult {
// Your logic here
Ok(SpelOutput::states_only(vec![
AccountPostState::new_claimed(state.account.clone(), Claim::Authorized),
AccountPostState::new(owner.account.clone()),
]))
}
#[instruction]
pub fn transfer(
#[account(mut, pda = literal("state"))]
state: AccountWithMetadata,
recipient: AccountWithMetadata,
#[account(signer)]
sender: AccountWithMetadata,
amount: u128,
) -> SpelResult {
// Your logic here
Ok(SpelOutput::states_only(vec![
AccountPostState::new(state.account.clone()),
AccountPostState::new(recipient.account.clone()),
AccountPostState::new(sender.account.clone()),
]))
}
}
Account Attributes
| Attribute | Description |
|---|---|
#[account(mut)] |
Account is writable |
#[account(init)] |
Account is being created (use new_claimed) |
#[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 |
#[account(pda = arg("create_key"))] |
PDA derived from an instruction argument |
members: Vec<AccountWithMetadata> |
Variable-length trailing account list |
PDA Seed Display
When the CLI derives PDA accounts during transaction execution, it prints the seed inputs used for each derivation:
PDA vault → 4Lp3gkH...
seeds: [program_id, "state"]
PDA token_account → 7xQ2m...
seeds: [program_id, Account(owner), Arg(create_key)]
Seeds always start with program_id, followed by the seeds declared in the account attribute. Constant strings appear quoted, account references as Account(name), and instruction arguments as Arg(name).
Runtime Validation
Accounts marked with #[account(signer)] or #[account(init)] get automatic runtime checks before your handler runs:
- Signer: Verifies
is_authorizedis true, returnsSpelError::Unauthorizedif not - Init: Verifies account is in default state, returns
SpelError::AccountAlreadyInitializedif not
No manual checking needed in your instruction handlers.
External Instruction Enum
If your Instruction enum lives in a shared core crate (used by both on-chain program and CLI), you can tell the macro to use it instead of generating one:
#[lez_program(instruction = "my_core::Instruction")]
mod my_program {
// ...
}
The CLI Wrapper
Every program gets a full CLI for free. The wrapper is just:
#[tokio::main]
async fn main() {
spel_cli::run().await;
}
This provides:
- Auto-generated subcommands from IDL instructions
- Type-aware argument parsing (u128, [u8; N], base58 accounts, ProgramId, etc.)
- Automatic PDA computation from IDL seeds
- risc0-compatible serialization
- Transaction building and submission with wallet integration
--dry-runmode for testinginspectsubcommand to extract ProgramId from binaries
Account Types
Types that represent on-chain account data can be annotated with #[account_type]. This causes them to appear in the generated IDL so spel inspect can decode raw account bytes into readable JSON.
use spel_framework::prelude::*;
#[account_type]
#[derive(BorshSerialize, BorshDeserialize)]
pub struct VaultState {
pub owner: AccountId,
pub balance: u128,
pub locked: bool,
}
#[account_type]
#[derive(BorshSerialize, BorshDeserialize)]
pub enum TokenHolding {
Fungible { definition_id: AccountId, balance: u128 },
NftMaster { definition_id: AccountId, print_balance: u128 },
}
Types referenced by an #[account_type] (such as helper enums or nested structs) are collected automatically — they do not need their own annotation:
// No annotation needed — picked up automatically because VaultState references it
#[derive(BorshSerialize, BorshDeserialize)]
pub enum VaultStatus { Active, Frozen }
The IDL generator embeds all annotated types in the accounts array and all transitively referenced helper types in the types array of the generated JSON. No file paths or external references — the IDL is fully self-contained.
IDL Generation
The IDL generator is also a one-liner:
spel_framework::generate_idl!("../methods/guest/src/bin/my_program.rs");
It reads the #[lez_program] annotations at compile time and generates a complete JSON IDL describing instructions, arguments, accounts, and PDA seeds.
LSSA-lang compatible fields
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
Each account field includes:
- 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.
CLI Usage
# Scaffold a new project (no --idl needed)
spel init my-program
# Inspect program binaries (no --idl needed)
spel inspect program.bin
# Generate IDL from a program source file (includes all #[account_type] definitions)
spel generate-idl methods/guest/src/bin/my_program.rs > my_program-idl.json
# Decode on-chain account data using a type from the IDL
spel inspect <account-id> --idl my_program-idl.json --type VaultState
# Same, but supply raw borsh bytes directly instead of fetching from the network
spel inspect <account-id> --idl my_program-idl.json --type VaultState --data <borsh-hex>
# Show available commands
spel --idl program-idl.json --help
# Dry run an instruction — resolve everything (PDAs, accounts, serialized data,
# signer nonces) and print without submitting. Accepts --dry-run (text default),
# --dry-run=text, or --dry-run=json.
spel --idl program-idl.json --dry-run -p program.bin -- \
create-vault --token-name "MYTKN" --initial-supply 1000000
# Machine-readable dry run for scripting / golden tests
spel --idl program-idl.json --dry-run=json -p program.bin -- \
create-vault --token-name "MYTKN" --initial-supply 1000000 | jq .
# Submit a transaction
spel --idl program-idl.json -p program.bin -- \
create-vault --token-name "MYTKN" --initial-supply 1000000
# Use --program-id instead of binary (skips loading the file)
spel --idl program-idl.json --program-id <64-char-hex> create-vault --token-name "MYTKN" --initial-supply 1000000
# Compute a PDA from the IDL
spel --idl program-idl.json --program-id <64-char-hex> pda vault --create-key my-multisig
# PDA derivation output shows seed inputs:
# PDA vault → 4Lp3gkH...
# seeds: [program_id, "state"]
# Auto-fill program IDs from binaries
spel --idl program-idl.json -p treasury.bin --bin-token token.bin \
create-vault --token-name "MYTKN" --initial-supply 1000000
# Get help for a specific instruction
spel --idl program-idl.json create-vault --help
Type Formats
| IDL Type | CLI Format |
|---|---|
u8, u32, u64, u128 |
Decimal number |
[u8; N] |
Hex string (2×N chars) or UTF-8 string (≤N chars, right-padded) |
[u32; 8] / program_id |
Comma-separated u32s: "0,0,0,0,0,0,0,0" |
Vec<u8> |
Comma-separated decimal bytes: "0,1,2" |
Vec<u32> |
Comma-separated decimal u32s: "0,200,0,0,0" |
Vec<[u8; 32]> |
Comma-separated hex or base58: "addr1,addr2" |
rest accounts |
Comma-separated base58/hex: --foo-account "addr1,addr2" |
Option<T> |
Value or "none" |
| Account IDs | Base58 or 64-char hex |
Inspecting Account Data
Once types are annotated with #[account_type] and the IDL is generated, you can decode any on-chain account into JSON:
# Generate the IDL (embeds all annotated account types)
spel generate-idl methods/guest/src/bin/token.rs > token-idl.json
# Fetch and decode a live account from the network
spel inspect 3f2a...bc01 --idl token-idl.json --type TokenHolding
Account: 3f2a...bc01
Data: 33 bytes
Hex: 01aabbccdd...
{
"NftMaster": {
"definition_id": "aabbccddee...",
"print_balance": "99"
}
}
For accounts with nested types (e.g. TokenMetadata referencing MetadataStandard), the IDL contains both and decoding works transparently:
spel inspect 9d1c...f4 --idl token-idl.json --type TokenMetadata
{
"definition_id": "aabbccddee...",
"standard": "Simple",
"uri": "https://example.com/metadata.json",
"creators": "Alice",
"primary_sale_date": "1720000000"
}
You can also pass raw borsh bytes directly with --data to decode without a network connection — useful during development and testing:
spel inspect 0000...0000 \
--idl token-idl.json \
--type TokenHolding \
--data 00<32-byte-definition-id-hex>00000000000000000000000000000064
Multi-Signature Transactions (Witness Exchange)
Some instructions need signatures from accounts whose keys live on different machines. A transaction's authorization is checked against its witness set, so all signers must sign the same message bytes, and those bytes contain every signer's nonce. The CLI coordinates this through a partial-transaction file that travels between signers.
# Machine A: build the full transaction, sign with local keys, and write
# the partial transaction to a file instead of submitting. Repeat
# --co-signer for each additional required signer. Needs a node
# connection to fetch nonces for all signers.
spel --idl program-idl.json -p program.bin \
--co-signer <account-id> --export handover.json -- \
admin-transfer --caller <current-admin> --evidence <new-admin> ...
# --export and --co-signer are global flags and belong before the "--"
# separator. Misplaced after it they are rejected with an error, the CLI
# never falls through to a live submission.
# The file travels to the co-signer over any channel.
# Machine B: inspect and sign. Shows the summary embedded by machine A
# next to fields decoded locally from the message bytes, asks for
# confirmation, then signs for every required signer whose key this
# wallet holds. Works offline.
spel sign handover.json
# Anyone: submit once all witnesses are collected. Needs a node.
spel submit handover.json
The file is JSON: the borsh-serialized message in hex, the ordered list
of required signers, and the collected witnesses. The signers order
matches the nonce order inside the message. The full format is
documented in spel-cli/src/blob.rs.
A signature authorizes exactly the message bytes in the file being
signed, and every already-collected witness is verified against those
same bytes before signing, so nobody can swap the message under
existing signatures. What the prompt cannot do is read intent into raw
instruction data. To verify a blob independently, rebuild the claimed
transaction on your own machine with the same arguments and --export,
and compare the decoded fields, instruction data included, against
what the prompt shows. The two exports differ only in nonces fetched
at different times.
A partial transaction goes stale when any listed signer's on-chain nonce changes. Signing is a local operation and cannot detect this, staleness surfaces at submission, which fails with a clear error, and the flow restarts with a fresh export.
--export works without --co-signer too, for preparing a transaction
on one machine and submitting it from another. It only supports public
transactions and cannot be combined with --dry-run.
Crates
| Crate | Description |
|---|---|
spel-framework |
Umbrella crate — re-exports macros + core with a prelude |
spel-framework-core |
IDL types, error types, SpelOutput |
spel-framework-macros |
Proc macros: #[lez_program], #[instruction], generate_idl! |
spel |
Generic IDL-driven CLI with TX submission + project scaffolding |
spel-client-gen |
Code generator — produces typed Rust FFI clients from IDL JSON |
Troubleshooting
Guest build fails with ring cross-compilation error on riscv32
If your guest build fails with:
riscv32-unknown-elf-gcc: error: unrecognized command-line option '-m64'
error: failed to run custom build command for `ring v0.17.14`
This is caused by the LEZ workspace enabling risc0-zkvm default features (bonsai, client), which pull in reqwest → rustls → ring. The ring crate cannot cross-compile for riscv32.
Root cause: logos-blockchain/logos-execution-zone issue #468
Workaround: fork the LEZ repo, apply this one-line change to Cargo.toml, and patch your workspace:
- risc0-zkvm = { version = "3.0.5", features = ["std"] }
+ risc0-zkvm = { version = "3.0.5", default-features = false, features = ["std"] }
Then in your workspace Cargo.toml:
[patch."https://github.com/logos-blockchain/logos-execution-zone.git"]
nssa_core = { git = "https://github.com/YOUR-USER/logos-execution-zone.git", branch = "fix-risc0-defaults" }
nssa = { git = "https://github.com/YOUR-USER/logos-execution-zone.git", branch = "fix-risc0-defaults" }
Once the upstream fix is merged, remove the [patch] section and update your LEZ dependency tag.
License
MIT