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
SwitchGuardianworkflows 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:
- A proposer pushes a delta (transaction plan) to Guardian. GUARDIAN tracks who signed and emits an ack signature once the threshold is met.
- Cosigners fetch pending deltas, verify details locally, sign the transaction summary, and push signatures back to GUARDIAN.
- 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.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:
[]
= { = "https://github.com/OpenZeppelin/guardian", = "miden-multisig-client" }
Quick Start
use Endpoint;
use ;
use ;
# async
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 funded by the faucet identified in the block header's
fee_parameters.fee_faucet_id, then consume that note through a
consume_notes proposal. The bootstrap transaction can pay its fee from the
note it consumes.
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 ;
let rpc_config = new
.with_timeout_ms?
.with_retry_policy;
let mut client = builder
.miden_endpoint
.guardian_endpoint
.account_dir
.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 = builder
.miden_endpoint
.note_transport_endpoint
.guardian_endpoint
.account_dir
.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 ;
let prover_config = new
.with_url?
.with_retry_policy;
Core Workflow Examples
Propose ➜ Sign ➜ Execute
use TransactionType;
use AccountId;
let recipient = from_hex?;
let faucet = from_hex?;
let tx = transfer;
// 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 NoteType;
let tx = transfer_with_note_type;
// 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 NonZeroU32;
use P2ideHeights;
let tx = transfer_p2ide;
// Proposer creates the delta on GUARDIAN
let proposal = client.propose_transaction.await?;
// Second cosigner lists available proposals and signs the matching one
let proposals = client.list_proposals.await?;
let to_sign = proposals
.iter
.find
.expect;
client.sign_proposal.await?;
// Once threshold is met, any cosigner can execute
client.execute_proposal.await?;
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. Record an abandon intent and poll for the resolution:
use ;
// The nonce the proposal was pushed with (committed account nonce + 1).
let state = client.abandon_candidate.await?;
assert_eq!;
// 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
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 ;
let tx = switch_guardian;
match client.propose_with_fallback.await?
Fully Offline Signing and Execution
use TransactionType;
let tx = switch_guardian;
let mut exported = client.create_proposal_offline.await?;
// Cosigner signs locally
client.sign_imported_proposal.await?;
write?;
// Once enough signatures are collected offline:
client.execute_imported_proposal.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
List notes from a specific faucet with a minimum amount filter:
use NoteFilter;
let faucet = from_hex?;
let filter = by_faucet_min_amount;
let spendable = client.list_consumable_notes_filtered.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 Serializable;
use ;
use NoteType;
// Producer: build a transaction and propose it under a custom label.
let salt = generate_salt;
let mut request = build_p2id_transaction_request?;
let proposal = client.propose_custom_transaction.await?;
// Cosigners review and sign through the usual list/sign flow.
// Producer (once threshold is met): bind-check the request, fetch the validated
// advice, inject it into the request, and submit. `prepare_custom_execution`
// verifies the request against the signed commitment *before* the GUARDIAN ack,
// re-executing at the proposal's anchored reference block; `submit_transaction`
// takes the proposal id to execute at that same anchor, since the collected
// signatures only authorize the summary produced there.
let advice = client.prepare_custom_execution.await?;
request.advice_map_mut.extend;
client.submit_transaction.await?;
The integration keeps only its own recipe (build inputs + salt) 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 declares the recipe's salt with
TransactionRequestBuilder::fee_conversion_salt. When the request executes,
miden-client derives the native 1/1 conversion info from that execution's
reference header and commits it into the auth argument. A producer assembling a
different custom request directly with TransactionRequestBuilder must declare
its salt the same way.
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: = None;
loop
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.await?;
// `None` runs every strategy over the full chain and syncs afterwards.
let report = client.recover_notes.await?;
println!;
for problem in &report.problems
Strategies are individually selectable and the backfill's block range can be bounded:
use ;
let report = client
.recover_notes
.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 — rescans the full private-note transport backlog for every tracked tag, regardless of the stored cursor (and without ever regressing it). 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 asInvalidoutcomes 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
NoteScreenerexactly like normal sync, and unscannable sub-ranges are reported inuncoveredinstead 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_versionabsent on the wire. The proposal carries onlynote_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 withMultisigError::LegacyConsumeNotesNoteMissingand the cosigner cannot sign. This is the gap the v2 shape closes. - v2 (self-contained) —
consume_notes_metadata_version: 2plus aconsume_notes_notesarray carrying base64-serializedNotebytes aligned by index withnote_ids. Verification rebuilds the request from the embedded notes alone — no local-store read, no network call. This restores the same "rebuild from signed metadata" invariant every other proposal type already satisfied (and that audit finding M-08 remediated forp2id).
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 four 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 |
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!