miden-multisig-client 0.18.1

High-level SDK for interacting with multisig accounts on Miden via GUARDIAN
docs.rs failed to build miden-multisig-client-0.18.1
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Visit the last successful build: miden-multisig-client-0.18.0

Miden Multisig Client

High-level Rust SDK built on top of miden-client for private multisignature workflows on Miden. The crate wraps the on-chain multisig contracts plus Guardian coordination so you can:

  • create multisig accounts, register them with a GUARDIAN, and keep state off-chain,
  • propose, sign, and execute transactions with threshold enforcement,
  • fall back to offline SwitchGuardian workflows when connectivity is limited,
  • export/import proposals as files for sharing using side channels,

How Private Multisigs & GUARDIAN Work

Miden multisig accounts store their authentication logic on-chain, but their state (signers, metadata, proposals) is kept private. GUARDIAN acts as a coordination server:

  1. A proposer pushes a delta (transaction plan) to Guardian. GUARDIAN tracks who signed and emits an ack signature once the threshold is met.
  2. Cosigners fetch pending deltas, verify details locally, sign the transaction summary, and push signatures back to GUARDIAN.
  3. Once ready, any cosigner builds the final transaction using all cosigner signatures + the GUARDIAN ack, executes it on-chain.

Miden compatibility

This package's version and Miden's are not aligned. Pick the release that matches your Miden node:

This package Miden protocol
0.18.x 0.17.x (pre-release)
0.17.x 0.16.x
0.16.x 0.15.x
0.15.x 0.15.x

Adopting a new Miden line has twice required an irreversible reset of Guardian-stored account data, so an upgrade is not a drop-in. Full matrix, the breaking changes per line, and what each upgrade does to stored data: MIDEN_COMPATIBILITY.md.

Installation

Add the crate to your workspace (already available in this repo). From another project:

[dependencies]
miden-multisig-client = { git = "https://github.com/OpenZeppelin/guardian", package = "miden-multisig-client" }

Quick Start

use miden_client::rpc::Endpoint;
use miden_multisig_client::{MultisigClient, TransactionType};
use miden_objects::Word;

# async fn example() -> anyhow::Result<()> {
let signer1: Word = /* your RPO Falcon commitment */ Word::default();
let signer2: Word = Word::default();

let mut client = MultisigClient::builder()
    .miden_endpoint(Endpoint::try_from("http://localhost:57291")?)
    .guardian_endpoint("http://localhost:50051")
    // Directory where the underlying miden-client SQLite store will live
    .account_dir("/tmp/multisig")
    // Generate a new Falcon keypair for GUARDIAN authentication (builder can also accept your own key)
    .generate_key()
    .build()
    .await?;

let account = client.create_account(2, vec![signer1, signer2]).await?;
println!("Account registered on GUARDIAN endpoint: {}", client.guardian_endpoint());
# Ok(())
# }

On a network with a non-zero verification_base_fee, a new account needs the native fee asset before it can create or execute regular proposals. Send the account a note of the chain's fee asset (its faucet is the one the synced protocol configuration names, synced_fee_faucet_id), then consume that note through a consume_notes proposal. The bootstrap transaction can pay its fee from the note it consumes.

Syncing with GUARDIAN

client.sync() syncs the local store with the Miden node, then with GUARDIAN. The GUARDIAN step starts with a canonical-nonce pre-check (get_canonical_nonce, issue #191). When GUARDIAN's canonical nonce is below the local account's nonce, or equal to it at the same commitment, nothing newer exists to pull and the full state fetch is skipped. Otherwise the full sync runs as it did before the pre-check: a GUARDIAN state ahead of the local account is fetched and reconciled, and an equal nonce at a different commitment (divergence) is fetched but leaves the local account in place. A failed pre-check is a sync error, not a silent fall-through to the full fetch, so the GUARDIAN server must serve GetCanonicalNonce (issue #191) before this SDK version is rolled out against it.

Signing Guardian requests with a session

The key manager signs every Guardian request unless a session is active. start_session has it sign one session grant instead; a delegated P-256 key then signs the state and delta reads and the proposal list, get, create and sign requests of every later operation:

use std::time::Duration;
use miden_multisig_client::StartSessionOptions;

let info = client
    .start_session(StartSessionOptions {
        network: "devnet".to_string(), // the Guardian's network: local, devnet or testnet
        ttl: Duration::from_secs(3600), // more than 5 minutes, at most the Guardian's maximum
    })
    .await?;

client.sync().await?; // the GUARDIAN step is signed by the session key

client.end_session().await?; // log this session out
client.revoke_all_sessions().await?; // every session of this key, signed by the key manager

Account registration, delta pushes, candidate abandons, account lookup and transaction approvals keep using the key manager. A session Guardian ends or no longer accepts is dropped by the operation that learns of it, after which session() returns None and the key manager signs again. Starting a session logs out the one it replaces, and set_guardian_endpoint drops the session. revoke_all_sessions ends the sessions already registered; a grant signed but not yet registered can still be registered for about 10 minutes, so run it again 10 minutes later when a key may be compromised.

Configuration

Beyond the endpoints and the account directory, the builder carries three optional configuration surfaces. The cross-SDK reference, including the TypeScript equivalents, is docs/MULTISIG_SDK.md.

Miden node RPC (rpc_config)

RpcConfig sets the per-request gRPC deadline and the retry budget for idempotent node reads. The policy wraps the node client itself, so every read this SDK or the underlying miden-client issues (syncs, account, note, and block lookups) is covered.

use miden_multisig_client::{RpcConfig, RpcRetryPolicy};

let rpc_config = RpcConfig::new()
    .with_timeout_ms(15_000)?
    .with_retry_policy(RpcRetryPolicy::new(3));

let mut client = MultisigClient::builder()
    .miden_endpoint(Endpoint::devnet())
    .guardian_endpoint("http://localhost:50051")
    .account_dir("/tmp/multisig")
    .rpc_config(rpc_config)
    .generate_key()
    .build()
    .await?;

The defaults are a 10-second deadline and two total attempts (one classified, jittered retry); RpcRetryPolicy::new(1) opts out. Rate limiting and transport-shaped connection failures retry. Permanent failures (invalid argument, not found, authentication, TLS or certificate problems, invalid endpoint) return immediately without consuming the budget, and once the budget is exhausted the final upstream error is returned unchanged.

Transaction submission is never retried under any configuration: a submission whose outcome is unknown could execute twice if re-sent. The same policy covers the note transport, where fetches and stream establishment retry but note sends are never retried in-call (the client's relay outbox re-sends undelivered notes on later syncs).

Note transport endpoint (note_transport_endpoint)

Private notes are relayed through a note transport service that is separate from the node RPC. The testnet and devnet presets derive the transport endpoint automatically; a custom node endpoint has no derivable transport service, so private-note relay stays disabled until this is set explicitly.

let mut client = MultisigClient::builder()
    .miden_endpoint(Endpoint::try_from("https://my-node.internal:57291")?)
    .note_transport_endpoint("https://my-transport.internal")
    .guardian_endpoint("http://localhost:50051")
    .account_dir("/tmp/multisig")
    .generate_key()
    .build()
    .await?;

Remote prover (prover_config)

ProverConfig overrides the remote prover endpoint and the proof retry policy. Without it, the devnet and testnet presets use their public provers and every other endpoint proves locally. A custom URL must be absolute HTTP(S) and never falls back to a default endpoint. Remote proving gets two total attempts by default and local proving is never retried; retries apply only to transient proof failures, so transaction execution, submission, local state application, and GUARDIAN calls each run once.

use miden_multisig_client::{ProverConfig, ProverRetryPolicy};

let prover_config = ProverConfig::new()
    .with_url("https://prover.example")?
    .with_retry_policy(ProverRetryPolicy::new(4));

Core Workflow Examples

Propose ➜ Sign ➜ Execute

use miden_multisig_client::TransactionType;
use miden_objects::account::AccountId;

let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b")?;
let faucet = AccountId::from_hex("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c")?;
let tx = TransactionType::transfer(recipient, faucet, 1_000);

// P2ID notes are public by default. For a private note (only its hash is
// published on chain; the recipient needs the note shared out-of-band) use
// `transfer_with_note_type`. `NoteType` is re-exported from `miden_protocol::note`.
use miden_protocol::note::NoteType;
let tx = TransactionType::transfer_with_note_type(recipient, faucet, 1_000, NoteType::Private);

// For a reclaimable and/or timelocked send use `transfer_p2ide` (issue #366):
// presence of either height creates a P2IDE note instead of a plain P2ID note.
// `P2ideHeights` uses `NonZeroU32` — the invalid zero height ("no constraint"
// on-chain) is unrepresentable.
use std::num::NonZeroU32;
use miden_multisig_client::P2ideHeights;

let tx = TransactionType::transfer_p2ide(
    recipient, faucet, 1_000, NoteType::Public,
    P2ideHeights {
        reclaim: NonZeroU32::new(500_000), // sender may reclaim from this block on
        timelock: None,                    // no consume-not-before constraint
    },
);

// Proposer creates the delta on GUARDIAN
let proposal = client.propose_transaction(tx).await?;

// Second cosigner lists available proposals and signs the matching one
let proposals = client.list_proposals().await?;
let to_sign = proposals
    .iter()
    .find(|p| p.id == proposal.id)
    .expect("proposal not found");
client.sign_proposal(&to_sign.id).await?;

// Once threshold is met, any cosigner can execute
client.execute_proposal(&proposal.id).await?;

Changing the signer set or the threshold

All three go through the same on-chain procedure, which takes a signer set and a threshold together, so each is that call with different arguments.

use miden_multisig_client::TransactionType;

// Membership. The new set is derived from the current one, so a caller cannot
// supply a set that drops a signer by accident. The threshold is left alone.
let tx = TransactionType::add_cosigner(new_commitment);
let tx = TransactionType::remove_cosigner(commitment);

// Threshold only. Pass the account's current signer set unchanged.
let account = client.account().expect("account loaded");
let tx = TransactionType::update_signers(3, account.cosigner_commitments());

let proposal = client.propose_transaction(tx).await?;

update_signers moves the threshold and nothing else. A set that differs from the account's current one is refused, pointing at AddCosigner and RemoveCosigner, because deriving membership from current state is what makes those two safe. Order does not matter: the set is compared as a set, then the request is built from the account's own ordering so storage indices stay put. The threshold must be between 1 and the number of signers, and must differ from the current one.

This is the account-wide default threshold, the "N" in N-of-M. It is not the same as a per-procedure override, which TransactionType::UpdateProcedureThreshold sets and which takes precedence for the procedure it names. So a proposal's required signatures come from the override when one exists, and this default otherwise. Changing the default does not clear an override, and this call is itself gated by whatever threshold governs update_signers.

The TypeScript SDK exposes the same three as createAddSignerProposal, createRemoveSignerProposal and createChangeThresholdProposal.

Proposal verification status

list_proposals checks every proposal's metadata against its signed summary and records the outcome in proposal.verification (ProposalVerification::Unchecked | Verified | Failed { retryable, message }). A proposal that fails the check is still listed, so one stale or corrupt proposal cannot hide the others; only the check itself writes Verified, and a freshly parsed or imported proposal is Unchecked. Failed { retryable: true } means the re-execution hit a transient node error and the proposal may verify on the next listing; retryable: false means it cannot be reproduced (tampered metadata, for example) and has to be re-proposed. Verification is deliberately not part of ProposalStatus: a fully signed proposal can be dead, so Ready keeps meaning "threshold met" and proposal.is_actionable() answers "verified and ready". sign_proposal and execute_proposal re-verify the one proposal they act on and fail with the real error. A payload that does not parse at all still fails the listing, so malformed GUARDIAN data is never silently dropped.

Verification and execution run at the chain tip. Since Miden 0.17 a multisig summary binds the block its auth args name (the bound block), and every request the SDK builds declares that block (TransactionRequestBuilder::block_numbers), so the summary reproduces at any later tip. An execution runs at the Miden client's sync height, so list_proposals, sign_proposal, execute_proposal and the custom and offline paths sync the client first. Foreign accounts, the fee faucet among them, load at the tip, so a proposal stays verifiable however long it waits for signatures, even after the node has pruned the bound block's account state (devnet keeps about 50 blocks). Proposals also record the bound block as bound_block_num; this SDK and the TypeScript one read the block from the signed summary and refuse a proposal whose bound_block_num disagrees.

Recovering From a Dead Transaction (Abandon)

If execute_proposal dies after guardian approval (RPC submit failure, prover timeout, crash), the approved candidate keeps the account locked on GUARDIAN: proposals and deltas answer conflict_pending_delta while the account's candidate queue (one candidate by default) is full, when they build on the state that candidate already claimed, or when their nonce does not extend the queue (a proposal's must be the newest queued candidate's plus one), and any candidate queued behind it can never land. Record an abandon intent and poll for the resolution:

use miden_multisig_client::{AbandonRequestState, AbandonStatus};

// The nonce the proposal was pushed with (committed account nonce + 1).
let state = client.abandon_candidate(nonce).await?;
assert_eq!(state, AbandonRequestState::Pending);

// The guardian's worker confirms over a short quarantine (typically well
// under a minute) that the transaction did not land, then releases the
// account.
loop {
    match client.abandon_status(nonce).await? {
        AbandonStatus::Waiting => tokio::time::sleep(Duration::from_secs(5)).await,
        AbandonStatus::Abandoned => break,           // account released
        AbandonStatus::Landed => break,              // tx landed after all
        AbandonStatus::Unexpected => anyhow::bail!("unexpected candidate state"),
    }
}

Fallback to Offline (if GUARDIAN unavailable)

If the GUARDIAN endpoint can’t be reached, the SDK can produce an offline proposal only for SwitchGuardian transactions:

use miden_multisig_client::{ProposalResult, TransactionType};

let tx = TransactionType::switch_guardian("https://new-guardian.example.com", new_guardian_commitment);
match client.propose_with_fallback(tx).await? {
    ProposalResult::Online(p) => {
        println!("Proposal {} is live on GUARDIAN", p.id);
    }
    ProposalResult::Offline(exported) => {
        let path = "proposal_offline.json";
        std::fs::write(path, exported.to_json()?)?;
        println!("GUARDIAN unavailable. Share {} with cosigners, collect signatures, then run `execute_imported_proposal` once ready.", path);
    }
}

Fully Offline Signing and Execution

use miden_multisig_client::TransactionType;

let tx = TransactionType::switch_guardian("https://guardian.example.com", new_guardian_commitment);
let mut exported = client.create_proposal_offline(tx).await?;

// Cosigner signs locally
client.sign_imported_proposal(&mut exported).await?;
std::fs::write("proposal_signed.json", exported.to_json()?)?;

// Once enough signatures are collected offline:
client.execute_imported_proposal(&exported).await?;

Listing Notes

List all notes that are currently consumable by the loaded account:

let notes = client.list_consumable_notes().await?;
for note in notes {
    println!("Note {} has {} assets", note.id.to_hex(), note.assets.len());
}

List notes from a specific faucet with a minimum amount filter:

use miden_multisig_client::NoteFilter;

let faucet = AccountId::from_hex("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c")?;
let filter = NoteFilter::by_faucet_min_amount(faucet, 5_000);
let spendable = client.list_consumable_notes_filtered(filter).await?;

Custom Proposal Types

GUARDIAN accepts any non-empty proposal_type, so an integration can propose a transaction the SDK does not model (an agglayer bridge note, an arbitrary dApp transaction) under its own label. The SDK normalizes the label to lowercase snake_case (trims and lowercases it, then requires [a-z0-9_]+ — the same shape as built-in labels like add_signer), so "B2Agg" becomes b2agg and "add signer" / "add-signer" are rejected. Such a proposal buckets to TransactionType::Custom and keeps its normalized label in ProposalMetadata.proposal_type. It can be listed, signed, and exported/imported through the normal flow, but the generic SDK cannot build its on-chain transaction — the integration owns that recipe and drives execution.

use miden_client::Serializable;
use miden_multisig_client::{
    build_p2id_transaction_request, generate_salt, P2ideHeights,
};
use miden_protocol::note::NoteType;

// Producer: build a transaction and propose it under a custom label. The account's
// auth procedure reads three words out of the request's auth argument since Miden
// 0.17 (bound block and approval expiration, salt, fee conversion info); the client
// builds them, bound to its sync height, and the builder attaches them.
// `propose_custom_transaction` derives the summary at the chain tip, syncing up to
// the bound block first if the client is behind it.
client.sync().await?;
let salt = generate_salt();
let auth_args = client.multisig_auth_args(salt, None, None).await?;
let mut request = build_p2id_transaction_request(
    account.inner(),
    recipient,
    vec![asset],
    NoteType::Public,
    P2ideHeights::default(),
    &auth_args,
    std::iter::empty(),
)?;
let proposal = client.propose_custom_transaction(&request.to_bytes(), "b2agg").await?;
let bound_block_num = proposal.tx_summary.block_number();

// Cosigners review and sign through the usual list/sign flow.

// Producer (once threshold is met): rebuild the request from the recipe, bound to
// the block the proposal binds, bind-check it, fetch the validated advice, inject
// it, and submit. `prepare_custom_execution` verifies the request against the
// signed commitment *before* the GUARDIAN ack, re-executing at the chain tip;
// `submit_transaction` executes at the tip too. Both work because the request
// declares the block it binds (`TransactionRequestBuilderExt::multisig_auth_args`
// does this), and refuse one that does not with `BoundBlockNotDeclared`.
let auth_args = client.multisig_auth_args(salt, Some(bound_block_num), None).await?;
let mut request = build_p2id_transaction_request(
    account.inner(), recipient, vec![asset], NoteType::Public, P2ideHeights::default(),
    &auth_args, std::iter::empty(),
)?;
let advice = client.prepare_custom_execution(&proposal.id, &request.to_bytes()).await?;
request.advice_map_mut().extend(advice);
client.submit_transaction(&proposal.id, request).await?;

The integration keeps its own recipe (build inputs + salt + the proposal's bound block) so it can reproduce the exact transaction at execute time - the SDK does not store the serialized request. The binding check guarantees the rebuilt transaction matches the commitment the cosigners signed.

Every exported transaction builder takes the MultisigAuthArgs and attaches them with TransactionRequestBuilderExt::multisig_auth_args: the commitment becomes the request's auth argument, the preimage goes into the advice map, which miden-client then leaves alone, and the block they bind is declared with block_numbers, so the request executes at the chain tip. A producer assembling a different custom request directly with TransactionRequestBuilder must do the same, and must not call fee_conversion_salt, which would let miden-client commit its own auth arg over them (the module docs of transaction/auth_args.rs explain what it commits today and why that does not fit). An approval expiration (1 to 65535 blocks, the furthest a transaction can expire after its reference block) is opt-in through ProposalOptions::approval_expiration_delta on propose_transaction_with_options, or the third argument of multisig_auth_args for a custom request; a rebuild reads the expiration the summary binds back with summary_approval_expiration_block_num.

Delta History

Render the account's confirmed history after recovery. One HistoryPage per call, newest-first by nonce, with typed note summaries:

let mut cursor: Option<String> = None;
loop {
    let page = client.delta_history(Some(50), cursor.take()).await?;
    for entry in &page.entries {
        // entry.status is HistoryEntryStatus::Canonical; notes carry a
        // typed tag, visibility, assets, and counterparties.
        println!("{} at {}", entry.nonce, entry.timestamp);
    }
    match page.next_cursor {
        Some(next) => cursor = Some(next),
        None => break,
    }
}

Only canonical (confirmed) deltas appear — pending proposals live on list_proposals() — and only transactions pushed through Guardian are visible to it.

Recovering Notes After Device Loss

Normal forward sync cannot see notes that landed behind the store's cursors. After pull_account on a recovered account, recover_notes runs the three recovery strategies as one flow — the private-note transport drain, the proposal-embedded note import, and the historical public-note backfill — and finishes with a normal sync so imported notes are verified and ready to consume. The flow is the one public entry point; the strategies are internal.

client.pull_account(account_id).await?;

// `None` runs every strategy over the full chain and syncs afterwards.
let report = client.recover_notes(None).await?;
println!("recovered {} notes", report.imported);
for problem in &report.problems {
    println!("step {} did not run: {}", problem.step, problem.reason);
}

Strategies are individually selectable and the backfill's block range can be bounded:

use miden_multisig_client::{NoteRecoveryOptions, PublicBackfillOptions};

let report = client
    .recover_notes(Some(NoteRecoveryOptions {
        proposal_import: false,
        backfill: PublicBackfillOptions {
            from_block: Some(1_690_000u32.into()),
            ..Default::default()
        },
        sync_after: false,
        ..Default::default()
    }))
    .await?;

The combined NoteRecoveryReport carries each strategy's own report (transport, proposal_import, backfill) untouched; a strategy that cannot run at all (GUARDIAN unreachable, chain tip unresolvable, broken local store) becomes a RecoveryStepProblem entry instead of aborting the flow, and retryable: true means rerunning the flow — which is idempotent — can plausibly recover more. In brief:

  • Transport drain — fetches the private-note transport backlog for every tracked tag until a fetch moves no tag's cursor. A recovered account's tag has no transport cursor yet, so its whole retained backlog is fetched. Bounded by the transport service's retention: a best-effort rescan, not a backup.
  • Proposal import — rebuilds importable notes from the bytes v2 consume-notes proposals embed (validated against the proposals' declared note ids) plus node-fetched inclusion proofs, so it works for private notes too. Per-note NoteImportOutcomes; corrupt proposals and notes are isolated as Invalid outcomes instead of blocking the rest.
  • Public backfill — tag-scoped historical scan (genesis to tip by default) importing discovered public notes with their proofs, never touching the global sync height; cost scales with matches, not range length. Discoveries are screened with the execution-based NoteScreener exactly like normal sync, and unscannable sub-ranges are reported in uncovered instead of failing the flow.

For the full report semantics see docs/MULTISIG_SDK.md.

Preserving notes across a guardian switch

Pending proposals do not survive a guardian switch — the new GUARDIAN is registered with bare account state — so the notes embedded in the old GUARDIAN's pending consume-notes proposals are the one recovery source recover_notes can no longer reach after the repoint. execute_proposal runs the proposal-import slice automatically on the switch-guardian path (against the old GUARDIAN, before the switch transaction executes), best-effort and bounded by a 30s timeout; the transport drain and public backfill deliberately do not run there (an intact local store loses nothing they rescan), and the offline switch flow skips the import entirely (it exists to avoid contacting the GUARDIAN). When repointing a client by hand — or before executing an offline switch while the old GUARDIAN is still reachable — request the same preservation first:

let report = client.preserve_pre_switch_proposal_notes().await;

Consume-notes metadata versions

consume_notes proposals come in two metadata shapes. The discriminator is the consume_notes_metadata_version field on the wire.

  • v1 (legacy) — consume_notes_metadata_version absent on the wire. The proposal carries only note_ids; the verifier rebuilds the transaction request by fetching each note from its own local Miden store. If the verifier does not have the note locally (cursor advanced past the block, store was wiped, private-note transport pruned the blob), verification fails with MultisigError::LegacyConsumeNotesNoteMissing and the cosigner cannot sign. This is the gap the v2 shape closes.

  • v2 (self-contained) — consume_notes_metadata_version: 2 plus a consume_notes_notes array carrying base64-serialized Note bytes aligned by index with note_ids. Verification rebuilds the request from the embedded notes, never from whatever notes the verifier's store happens to hold. This restores the same "rebuild from signed metadata" invariant every other proposal type already satisfied (and that audit finding M-08 remediated for p2id).

    The rebuild is not store-independent by itself, though: miden-client consumes each input note as authenticated when the local store holds its inclusion proof and as unauthenticated otherwise, and the two commit differently into the signed summary (issue #409). Authenticated is the canonical mode, so before every rebuild (list, sign, execute) the verifier authenticates the embedded notes: notes already authenticated locally are left alone, the rest get their inclusion proofs from the Miden node in one round trip and are imported into the local store as committed (with one sync if the store is behind the note's block). Verification therefore reads and writes the local store and contacts the node. Proposal creation does the same before the summary is derived, and refuses a note that is not yet committed on chain.

Proposal creation always emits v2 starting with this release; the proposer is the one party guaranteed to hold the notes locally. The per-proposal v2 payload is capped at MAX_CONSUME_NOTES_METADATA_BYTES (256 KiB) and the size is enforced at creation time so the failure surfaces to the proposer before any signature collection begins.

Error taxonomy

All five errors below carry a stable, cross-SDK string code via MultisigError::code(). The TS SDK exposes the same identifiers as Error.code.

Variant .code() When
NoteBindingMismatch consume_notes_note_binding_mismatch v2: notes.len() != note_ids.len(), or note.id() != note_ids[i]
UnsupportedMetadataVersion { found } consume_notes_unsupported_metadata_version Unrecognized version (including v1 on a cut-over build)
ConsumeNotesMetadataOversize { limit, actual } consume_notes_metadata_oversize v2 metadata serialization exceeds 256 KiB at creation
LegacyConsumeNotesNoteMissing { note_id } consume_notes_legacy_note_missing v1 path: local store does not contain the referenced note
ConsumeNoteNotAuthenticated { note_id, reason } consume_notes_note_not_authenticated A note could not be authenticated: not committed on chain yet, the node served no proof, the import failed, or the store could not verify it after a sync

Cut-over policy

The legacy-consume-notes Cargo feature (default-on in this transitional release) gates whether the crate accepts v1 metadata for verification. A future cut-over release will ship with default = [], at which point v1 proposals are refused with UnsupportedMetadataVersion { found: None } on every code path. Deployments should drain or re-propose any v1 consume_notes proposals in flight before upgrading past the cut-over client version. Tracked by spec 006-consume-notes-metadata.

Demo CLI

Run the Terminal UI demo in examples/demo, which exercises the same APIs for account management, note listing, proposal signing, and offline export/import.

Contributions and bug reports are welcome!