Miden Multisig SDK
An SDK for creating and managing multisignature accounts on the Miden network. Available for both TypeScript (web/browser) and Rust (native/server) environments.
New to Guardian? Read
docs/CONCEPTS.mdfor the trust model and state/delta lifecycle, anddocs/architecture/services.mdfor the server-side surface this SDK targets.
Table of Contents
Quick Start
Installation
The multisig sdk has as peer dependency on the miden-sdk, you will need to install both.
TypeScript (npm)
npm install @openzeppelin/[email protected] @miden-sdk/[email protected]
Rust (Cargo.toml)
[dependencies]
miden-multisig-client = "=0.18.0"
miden-client = "=0.17.0"
5-Minute Example
Create a 1-of-3 multisig account, propose a transfer, collect signatures, and execute.
TypeScript
import { MidenClient, AuthSecretKey } from '@miden-sdk/miden-sdk';
import { MultisigClient, FalconSigner } from '@openzeppelin/miden-multisig-client';
// 1. Setup clients (for useWorker, see "Installation & Setup" below)
const midenClient = await MidenClient.createDevnet({ useWorker: false });
const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined);
const signer = new FalconSigner(secretKey);
const client = new MultisigClient(midenClient, {
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://rpc.devnet.miden.io',
});
// Both endpoints are required; construction throws when either is omitted.
// midenRpcEndpoint must point at the same network as the injected MidenClient.
// 2. Get GUARDIAN server public key
const guardianCommitment = await client.guardianClient.getPubkey();
// 3. Create 1-of-3 multisig account
const config = {
threshold: 1,
signerCommitments: [signer.commitment, cosigner1Commitment, cosigner2Commitment],
guardianCommitment,
};
const multisig = await client.create(config, signer);
await multisig.registerOnGuardian();
console.log('Account created:', multisig.accountId);
// 4. Create a transfer proposal
const proposal = await multisig.createP2idProposal(
recipientAccountId,
faucetAccountId,
1000n // amount
);
console.log('Proposal created:', proposal.id);
// 5. Cosigners sign (only one cosigner is needed)
await multisig.signProposal(proposal.id);
// 6. Execute when threshold is met
await multisig.executeProposal(proposal.id);
console.log('Transfer executed!');
EIP-712 wallet signing (TypeScript)
Eip712Signer accepts a compatible EIP-1193 provider, including a Ledger
wallet. Connect it to discover the public key, then use it with the same
client.load, proposal creation, signProposal, and executeProposal calls as
other signers. LedgerSigner remains an alias.
Guardian request authentication uses the x-auth-format: eip712 header and
signs GuardianRequest. Proposal approvals use message_format: eip712 on
the ECDSA signature and sign MidenTransaction. These are separate messages
and signatures; enabling one format does not replace the other.
The proposal ID is the transaction-summary commitment, while the wallet signs an EIP-712 digest derived from it. See the TypeScript package README for a call-by-call example of the wallet prompts.
Prover endpoint and retry policy
Both multisig SDKs retry only remote transaction proving. The default is two total proof attempts. In TypeScript, endpoint-less injected provers, including local and callback provers, run once. A custom remote URL overrides the Miden client's injected prover.
const client = new MultisigClient(midenClient, {
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://rpc.devnet.miden.io',
prover: {
url: 'https://prover.example',
retry: { maxAttempts: 4 },
},
});
use miden_multisig_client::{ProverConfig, ProverRetryPolicy};
let prover = ProverConfig::new()
.with_url("https://prover.example")?
.with_retry_policy(ProverRetryPolicy::new(4));
let client = MultisigClient::builder()
.miden_endpoint(Endpoint::devnet())
.guardian_endpoint("http://localhost:50051")
.account_dir("/tmp/multisig-client")
.prover_config(prover)
.generate_key()
.build()
.await?;
URLs are validated during construction and must be absolute HTTP(S) URLs. A custom prover never falls back to a default endpoint. Retries cover transient proving conditions such as cancellation, deadlines, temporary unavailability, capacity exhaustion, HTTP 408/429/502/503/504, I/O timeout, connection reset, and broken pipe. Permanent or unrecognized failures return immediately.
Only proving is retried by this policy: transaction execution, GUARDIAN coordination, Miden submission, and local application each run once. A larger attempt budget can recover from brief failures but does not add prover capacity. Idempotent Miden node reads have their own opt-in retry policy — see the next section.
Miden RPC retry policy
Both multisig SDKs retry idempotent Miden node reads under an rpc policy
that mirrors the prover configuration. The default is two total attempts —
one classified, jittered retry — matching the prover policy's default; an
explicit maxAttempts of 1 opts out. Transaction submission is never
retried, under any configuration: a submission whose outcome is unknown
could execute twice if re-sent.
Coverage differs by language. In Rust, the policy wraps the node client itself, so every node read the SDK or the underlying Miden client issues — syncs, account, note, and block lookups — retries under it. The same policy also covers the private-note transport: note fetches and stream establishment retry, while note sends are never retried in-call (the client's relay outbox re-sends undelivered notes on later syncs, so an in-call resend could deliver twice). Configuring the transport endpoint itself is covered in Note transport endpoint. Connection failures retry only when the cause is transport-shaped; TLS, certificate, and invalid-endpoint problems fail immediately. In TypeScript, the policy wraps the reads the SDK issues directly: on-chain account lookups, state-commitment verification, and the guardian-switch node sync; syncs you perform on the injected Miden client are owned by your application. As part of this policy the Rust SDK also disables the Miden client library's internal transport-retry loop, which silently retransmitted rate-limited requests — including submissions — up to four extra times. This explicit policy is the only retry layer: reads get one retry by default, submissions never.
const client = new MultisigClient(midenClient, {
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://rpc.devnet.miden.io',
prover: { retry: { maxAttempts: 4 } },
rpc: { retry: { maxAttempts: 3 } },
});
use miden_multisig_client::{RpcConfig, RpcRetryPolicy};
let rpc = RpcConfig::new()
.with_timeout_ms(15_000)?
.with_retry_policy(RpcRetryPolicy::new(3));
let client = MultisigClient::builder()
.miden_endpoint(Endpoint::devnet())
.guardian_endpoint("http://localhost:50051")
.account_dir("/tmp/multisig-client")
.rpc_config(rpc)
.generate_key()
.build()
.await?;
The classifier is shared with the prover policy (same transient/permanent
partition and backoff), extended with the node's transport renderings: a
rate-limit rejection (Too Many Requests!), and connection failures the node
reports under an Unknown status (i/o timeout, connection error,
transport error). Permanent failures — invalid arguments, not-found,
authentication — return immediately without consuming the budget, and once
the budget is exhausted the final upstream error is returned unchanged. A
larger budget adds patience against a rate-limited or briefly unreachable
node; it does not add node capacity.
In Rust, the per-request gRPC deadline defaults to 10 seconds on every SDK
node connection (presets, custom endpoints, and the direct commitment
reads); with_timeout_ms replaces it uniformly.
The TypeScript configuration intentionally has no timeout: the browser
WASM RPC client cannot cancel an in-flight request, and a JavaScript-side
timeout would abandon the call — whose side effects may still land — while
reporting failure. Retry behavior itself is fixture-verified to be identical
across both SDKs.
The Guardian server exposes the same policy for its own node reads via
GUARDIAN_MIDEN_RPC_ENDPOINT, GUARDIAN_MIDEN_RPC_TIMEOUT_MS, and
GUARDIAN_MIDEN_RPC_MAX_ATTEMPTS — see
docs/CONFIGURATION.md.
Note transport endpoint
Private notes are relayed through a note transport service that is separate from the node RPC. On the testnet and devnet presets both SDKs derive the transport endpoint automatically; a custom node endpoint has no derivable transport service, so it must be set explicitly — otherwise private-note relay is disabled.
In Rust the endpoint lives on the builder, next to the node endpoint:
let 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-client")
.generate_key()
.build()
.await?;
In TypeScript the injected Miden client owns note relay, so the endpoint is
set when constructing it, not on the MultisigClient configuration:
import { MidenClient } from '@miden-sdk/miden-sdk';
import { MultisigClient } from '@openzeppelin/miden-multisig-client';
const midenClient = await MidenClient.create({
rpcUrl: 'https://my-node.internal:57291',
noteTransportUrl: 'https://my-transport.internal',
useWorker: false,
});
const client = new MultisigClient(midenClient, {
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://my-node.internal:57291',
});
noteTransportUrl also accepts the 'testnet' and 'devnet' shorthands, so
a custom node on a public network can reuse that network's public transport
service. In both SDKs the transport shares the node RPC resilience policy
described above.
Rust
use miden_multisig_client::{MultisigClient, TransactionType, Endpoint};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Setup client
let mut client = MultisigClient::builder()
// the Miden node RPC endpoint
.miden_endpoint(Endpoint::new("http://localhost:57291"))
// the GUARDIAN server endpoint
.guardian_endpoint("http://localhost:50051")
// the directory where the miden-client will store the account data
.account_dir("/tmp/multisig-client")
// generate a new Falcon keypair for GUARDIAN authentication
.generate_key()
.build()
.await?;
// 2. Create 1-of-3 multisig account
let signer_commitments = vec![
client.user_commitment(),
cosigner1_commitment,
cosigner2_commitment,
];
let account = client.create_account(1, signer_commitments).await?;
client.push_account().await?;
println!("Account created: {}", account.id());
// 3. Create a transfer proposal
let tx = TransactionType::transfer(recipient_id, faucet_id, 1000);
let proposal = client.propose_transaction(tx).await?;
println!("Proposal created: {}", proposal.id);
// 4. Cosigners sign (only one cosigner is needed)
client.sign_proposal(&proposal.id).await?;
// 5. Execute when threshold is met
client.execute_proposal(&proposal.id).await?;
println!("Transfer executed!");
Ok(())
}
Core Concepts
Multisig Accounts
A multisig account requires M-of-N signatures to authorize transactions:
- Threshold (M): Minimum signatures required
- Signers (N): Total number of authorized cosigners
- Commitment: Each signer's public-key commitment (32 bytes, 64 hex chars)
Guardian
GUARDIAN is a coordination server that:
- Stores the account state off-chain
- Coordinates proposal signing between cosigners
- Provides acknowledgment signatures for on-chain execution (ensures the new state is available for the rest of the cosigners)
- Keeps multisig metadata private
Note: GUARDIAN server setup is covered in separate documentation. This SDK assumes a running GUARDIAN instance.
Proposal Lifecycle
┌──────────┐ ┌──────────┐ ┌───────────┐
│ PENDING │ ──► │ READY │ ──► │ FINALIZED │
└──────────┘ └──────────┘ └───────────┘
│ │ │
Collecting Threshold Executed
signatures met on-chain
States:
- Pending: Proposal created, collecting signatures (shows X/Y signed)
- Ready: Threshold met, can be executed
- Finalized: Executed on-chain or discarded
Tip execution and the bound block
Since Miden protocol 0.17 a multisig transaction summary binds the block its
auth args name (the bound block), not the block the transaction executes
against. The proposer binds its sync height, and every request rebuilt from
the proposal binds the same block and declares it
(TransactionRequestBuilder::block_numbers / withBlockNumbers), which puts
it in the transaction's partial blockchain. Cosigners verify and the executor
executes at the chain tip and still reproduce the exact summary the
signatures authorize. An execution runs at the Miden client's sync height, so
the SDK syncs the client before it verifies, signs, or executes a proposal.
Foreign accounts, the fee faucet among them, therefore load at the tip, and a
proposal stays verifiable and executable however long it waits for signatures
(issue #462). A client whose node has not reached the bound block yet fails with
a retryable error.
Proposals still carry a chain anchor (chain_anchor in the proposal
metadata): a serialized Miden ChainAnchor at the bound block. Nothing
executes against it. It names the bound block for a rebuild, and it keeps
proposals readable by 0.18.0-rc.1 clients, which re-execute at it. The anchor
is validated on receipt: its internal consistency at deserialization, and its
block commitment against the one signed into the summary. A proposal without
an anchor cannot be verified or executed.
Authenticated note consumption
The bound block pins the block, but a consume_notes summary also depends on
how each input note is consumed. miden-client decides that per note, at
execution time and from the local store alone: a note whose record carries
its inclusion proof is consumed authenticated, anything else
unauthenticated, and the two commit differently into the summary
(hash(nullifier || note_id_or_ZERO)). Left to the store, a proposer that
had synced the notes and a cosigner on a fresh store would therefore sign
different commitments (issue #409).
Authenticated is the canonical mode. createConsumeNotesProposal /
TransactionType::consume_notes authenticates the notes in the proposer's
store before the summary and its anchor are captured, and every verifier
(syncProposals, signProposal, executeProposal, and their Rust
counterparts) authenticates the proposal's embedded notes before it
rebuilds: notes already authenticated locally are left alone, the rest get
their inclusion proofs from the Miden node in one round trip and are
imported as committed. Verification therefore needs the node, and a
consume-notes proposal can only be created for notes already committed on
chain; a note that cannot be authenticated fails with
ConsumeNoteNotAuthenticatedError (consume_notes_note_not_authenticated)
naming the note, rather than with a summary mismatch.
Mixed versions. A 0.18.0-rc.1 client still re-executes at the anchor, which loads every foreign account the transaction touches at the bound block, and every fee-paying transaction touches the fee faucet (the kernel's asset callbacks load a faucet whose account ID enables them, as devnet's does). Nodes serve historical account state only for a limited window, about 50 blocks (roughly 2.5 minutes) on devnet, so such a client reports
block N has been prunedfor an older proposal. Upgrade every party that signs or executes. A listing keeps working either way: a proposal that cannot be verified is returned withverificationset tofailedrather than failing the whole sync.
Proposal verification status
Every proposal a listing returns carries the outcome of its summary-binding
check: verification on the TS Proposal ({ status: 'unchecked' },
{ status: 'verified' }, or { status: 'failed', retryable, message }) and
ProposalVerification on the Rust one (Unchecked, Verified,
Failed { retryable, message }). Only the check itself writes verified;
a freshly parsed or imported proposal is unchecked. failed with
retryable: true means the re-execution hit a transient node error and
the same proposal may verify on the next sync; retryable: false means
the proposal cannot be reproduced (tampered metadata, for example) and has
to be re-proposed. Verification is kept out of status
on purpose: a proposal can be fully signed and dead at the same time, so
status: 'ready' keeps meaning "threshold met" and
isProposalActionable(proposal) / proposal.is_actionable() answers
"verified and ready". Signing and executing re-verify the one proposal
they act on and refuse a failed one with the real error.
Custom Proposal Types
Guardian accepts any non-empty proposal_type, not just the first-party
operations (issue #266). A proposal whose type the SDK does not model is
exposed as the custom bucket — TransactionType::Custom in Rust,
proposalType: 'custom' in TypeScript — while the label is preserved
(Rust ProposalMetadata.proposal_type, TypeScript CustomProposalMetadata.rawProposalType)
so it can be displayed. The SDK normalizes the label to lowercase snake_case
(trim + lowercase, then require [a-z0-9_]+ — the same shape as built-in
labels), so b2agg is accepted, B2Agg is lowercased to b2agg, and
add signer / add-signer are rejected. (Normalization is SDK-side; the server
itself still accepts any non-empty string.)
Custom proposals can be listed, displayed, signed, and exported/imported.
Producer API (issue #266). The integration that owns a custom type builds its own transaction and drives the create + execute ends; the SDK never executes a transaction it does not understand. The model is symmetric across Rust and TypeScript:
-
Create —
propose_custom_transaction(transaction_request_bytes, proposal_type)(Rust) /createCustomProposal(transactionRequestBytes, proposalType)(TS). The bytes are a serialized transaction request; the SDK derives the summary and pushes the proposal with the custom label. They are not stored on the server. Cosigners then review and sign through the normal flow.Since Miden 0.17 the request must carry the multisig auth args: the block its summary binds together with the approval expiration, the salt, and the native 1:1 fee conversion info, committed into the auth argument with the preimage in the advice map. In TypeScript, start from
client.feeAwareTransactionRequestBuilder(account, \{ feeConversionSalt, boundBlockNum, approvalExpirationDelta \})(the delta is 1 to 65535 blocks, the furthest a transaction can expire after its reference block) and never callwithFeeConversionSaltorwithAuthArgon it. In Rust, build them withclient.multisig_auth_args(salt, bound_block_num, approval_expiration_delta)and attach them withbuilder.multisig_auth_args(&auth_args)fromTransactionRequestBuilderExt; do not declarefee_conversion_salt, which would let miden-client commit its own auth arg over them. The typed proposal builders do this for you. A custom producer syncs before building:propose_custom_transaction/createCustomProposalanchor the proposal at the store's sync height without syncing again, and a request bound to an older block is refused. It must retain the original salt and rebuild at the proposal's anchor block (ChainAnchor::blockNum/ChainAnchor::block_num) with the expiration the summary binds;summarySalt(summary)/summary_salt(&summary)read the salt the cosigners signed over for a cross-check. The request has to declare its bound block, since proposals execute at the tip: both builders above do, and a request that binds a block without declaring it is refused withBoundBlockNotDeclaredError/MultisigError::BoundBlockNotDeclared. Serialize the request with a client on the SDK'smiden-clientpin (seeMIDEN_COMPATIBILITY.md). -
Execute —
prepare_custom_execution(proposal_id, transaction_request_bytes)(Rust) /prepareCustomExecution(proposalId, transactionRequestBytes)(TS). The SDK verifies the proposal is ready, binding-checks the request against the signed commitment (before any acknowledgment request), fetches the GUARDIAN ack, and returns the advice (cosigner signatures + ack). The integration injects that advice into its own transaction and submits via its own client:// TypeScript: rebuild via the integration's builder (the wasm request is immutable)
const advice = await multisig.prepareCustomExecution(proposalId, transactionRequestBytes);
const finalReq = myBuilder.extendAdviceMap(advice).build();
await multisig.submitTransaction(proposalId, finalReq);// Rust: inject into the request's advice map, submit via the SDK helper
let advice = client.prepare_custom_execution(&proposal_id, &transaction_request_bytes).await?;
let mut req = deserialize_transaction_request(&transaction_request_bytes)?;
req.advice_map_mut().extend(advice);
client.submit_transaction(&proposal_id, req).await?;
The SDK owns the security-critical pieces (binding check, signature + ack
assembly, ack-after-binding ordering); the integration owns only the
transaction recipe + submit. execute_proposal on a custom type returns a
clear error pointing to prepare_custom_execution. Because the integration must
rebuild its transaction to execute, custom execution is performed by a party
that holds the recipe (typically the producer), not by an arbitrary cosigner.
The returned advice is keyed by the signer and GUARDIAN commitments
(domain-separated digests over the signed tx_summary), the same keys the
SDK's own built-in execution uses. Extending a transaction's advice map with it
therefore does not collide with the transaction's ordinary inputs; the
integration extends rather than replaces its advice map.
Security: for first-party types the SDK reconstructs the transaction from metadata and checks it against the signed
tx_summarycommitment. For custom types there is no such reconstruction, so the SDK cannot verify that display metadata (e.g.description) matches what the transaction actually does. Cosigners must verify the rawtx_summarythey are signing — not trust the label or description.
Side-channel (offline) workflow
For moving a proposal between cosigners as a document instead of through GUARDIAN's pending set.
This is off-channel signature collection, not air-gapped operation. Nothing here avoids the chain: executing any proposal,
switch_guardianincluded, submits a transaction to the Miden network. Whatswitch_guardianalone avoids is the current GUARDIAN, which is the point of it, since the flow exists for leaving a GUARDIAN that will not cooperate. Even that path reaches the network to verify the new endpoint and to sync before it builds the proposal. For every other type the signing step reproduces the transaction to verify the summary, so the signer needs a synced store (and, forconsume_notes, the node), and execution needs an acknowledgement from GUARDIAN. A transfer executed this way still reaches GUARDIAN.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Proposer │ │ Cosigner │ │ Executor │
│ (Online) │ │ (Off-channel)│ │ (Online) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ Export proposal.json │ │
│──────────────────────►│ │
│ │ │
│ │ Sign offline │
│ │ │
│ │ Export signed.json │
│ │──────────────────────►│
│ │ │
│ │ Import & Execute
▼ ▼ ▼
TypeScript SDK Guide
Installation & Setup
import { MidenClient, AuthSecretKey } from '@miden-sdk/miden-sdk';
import {
MultisigClient,
Multisig,
FalconSigner,
AccountInspector,
type MultisigConfig,
} from '@openzeppelin/miden-multisig-client';
// Initialize Miden client (connects to Miden node)
const midenClient = await MidenClient.createDevnet({ useWorker: false });
// Create signer from secret key
const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined);
const signer = new FalconSigner(secretKey);
// Initialize multisig client
const client = new MultisigClient(midenClient, {
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://rpc.devnet.miden.io',
});
The SDK does all of its local work through the injected MidenClient and never
opens a second client on its store, so pass the client your application executes
transactions with: miden-client keeps account state in memory per client, and a
second live client writing the same store can persist a storage root computed
from state it never saw. midenRpcEndpoint serves only the SDK's direct node
reads (on-chain commitments and note inclusion proofs).
In a browser, create that client with useWorker: false, which runs its WASM
work on the page's main thread. In the default worker mode, transactions
execute and apply in a Web Worker that never sees the account state the SDK
writes with accounts.insert, so a device's local copy of the account breaks
after MultisigClient.load or a syncState() import. The symptoms and
recovery are in
TROUBLESHOOTING.md,
the upstream status in
MIDEN_COMPATIBILITY.md. Node.js
clients have no worker and are unaffected.
Creating Accounts
// Get GUARDIAN server's public key commitment
const guardianCommitment = await client.guardianClient.getPubkey();
// Define multisig configuration
const config: MultisigConfig = {
threshold: 2, // 2 signatures required
signerCommitments: [ // 3 authorized signers
signer.commitment, // Your commitment
'0x1234...abcd', // Cosigner 1
'0x5678...efgh', // Cosigner 2
],
guardianCommitment, // GUARDIAN server commitment
};
// Create the account
const multisig = await client.create(config, signer);
// Register with GUARDIAN (stores initial state)
await multisig.registerOnGuardian();
console.log('Account ID:', multisig.accountId);
console.log('Threshold:', multisig.threshold);
console.log('Signers:', multisig.signerCommitments);
Loading Existing Accounts
// Load as a cosigner joining an existing multisig
const multisig = await client.load(accountId, signer);
// Fetch latest state from GUARDIAN
const state = await multisig.fetchState();
// Inspect account configuration
const detected = AccountInspector.fromBase64(state.stateDataBase64);
console.log('Threshold:', detected.threshold);
console.log('Signers:', detected.signerCommitments);
console.log('Vault balances:', detected.vaultBalances);
load() reconciles GUARDIAN's account with the local store under the same
rule syncState() uses: it keeps local state when GUARDIAN is behind (a pushed
delta not yet canonicalized), adopts GUARDIAN's when it is ahead and matches the
on-chain commitment, and throws on divergence. Loading an account that has
transacted into an empty store reads its commitment from the Miden node, so the
node must be reachable, and it throws while GUARDIAN's canonical state still
lags the chain; retry once GUARDIAN has canonicalized. The Rust pull_account
does not reconcile (see below).
Delta History
Guardian retains the account's canonical delta history, allowing a wallet to
render its history after recovery. deltaHistory() returns one
page at a time, newest-first by nonce, with server-decoded input and output
note summaries: note ID, P2ID/P2IDE/swap/mint/burn classification, note
visibility (noteType), assets, and sender or recipient when exposed by the
note script. Every entry carries status: 'canonical' today; the set widens
if the feed gains a status filter.
let cursor: string | undefined;
do {
const page = await multisig.deltaHistory({ limit: 50, cursor });
for (const entry of page.entries) {
console.log(`nonce ${entry.nonce} at ${entry.timestamp}`);
for (const note of entry.outputNotes) {
console.log(` sent ${note.tag} note ${note.noteId}`);
}
if (entry.decodeWarnings.length > 0) {
// Payload predates the current summary format; sections are empty.
}
}
cursor = page.nextCursor;
} while (cursor !== undefined);
limit accepts 1–500 (default 50). Only canonical (confirmed)
transactions appear — pending proposals live on syncProposals(). The
feed is served even while the account is paused. Guardian only ever
sees transactions pushed through it, so history of transactions the
account executed elsewhere is not included. Output notes whose full
details are not in the stored summary (e.g. private notes carried as
partial notes) appear with tag: 'custom' and no recipient.
Recovering Notes After Device Loss
Normal forward sync cannot see notes that landed behind the store's
cursors. After load() on a recovered account, multisig.recoverNotes()
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 (transport fetch, chain sync,
and GUARDIAN state sync) so imported notes are verified and ready to
consume. The flow is the one public entry point: the strategies are
internal, so a caller cannot accidentally skip the context they need (a
tracked account, a synced store) or the final verifying sync.
const multisig = await client.load(accountId, signer);
const report = await multisig.recoverNotes();
console.log(`recovered ${report.imported} notes`);
for (const problem of report.problems) {
// A strategy that could not run at all lands here; the flow continues
// with the remaining strategies either way.
console.warn(`step ${problem.step} did not run: ${problem.reason}`);
}
if (report.retryable) {
// The flow is idempotent — rerun it to plausibly recover more.
}
Strategies are individually selectable and the backfill's block range can be bounded:
// Rescan only the transport backlog and recent public history, skipping the
// proposal import and the final verifying sync.
const report = await multisig.recoverNotes({
proposalImport: false,
fromBlock: 1_690_000,
syncAfter: false,
});
The combined NoteRecoveryReport carries each strategy's own report
(transport, proposalImport, backfill) untouched, plus the problems
list, synced, the total imported count, and an aggregate retryable
flag.
The transport-drain strategy
A fresh store has no note-transport cursor — and in a store shared with other accounts, sync may have advanced the cursor past private notes addressed to the recovered account. The drain rescans the full transport backlog for every tracked note tag (running as many transport syncs as the upstream per-sync tag backfill cap requires), regardless of the stored cursor, and never regresses it. It is idempotent, and a failed drain restores the covered-tags bookkeeping so normal sync keeps working exactly as before the attempt.
Transport problems land in report.transport, never as exceptions:
unavailable means no transport endpoint is configured or the transport
could not be reached before anything was imported; failed with
retryable: true keeps any partial progress and rerunning continues it.
Not a backup: transport recovery is bounded by the transport service's retention. Senders may deliver private notes out-of-band without using the transport, and relayed blobs are pruned after the retention window. Notes outside both are recoverable only from their sender (see the note export/import helpers).
The proposal-import strategy
v2 consume_notes proposals embed the serialized notes they consume, so
pending proposals are opportunistic recovery material: the strategy syncs
them from GUARDIAN (isolating per-proposal parse or binding failures as
invalid outcomes instead of failing the whole step), validates each
embedded note against the proposal's declared note ids, fetches on-chain
inclusion proofs, and imports per note — so it works for private notes too;
the node never needs to hold the note body. Binding verification itself
already authenticates a proposal's embedded notes into the store (see
Authenticated note consumption), so a proposal that verified reports its
notes as already present here; the import step still covers notes whose
proposal failed verification for an unrelated reason.
Each unique embedded note gets a NoteImportOutcome in
report.proposalImport (imported, already-present, already-consumed,
not-committed, invalid, or failed); duplicates across proposals fold
into one outcome and no per-note problem blocks the others. A note not yet
on chain is recorded as expected with its sync-hint tag so a later sync
picks it up, and an import whose inclusion proof fails verification against
the authenticated chain is demoted to failed rather than counted as
recovered.
Proposals are opportunistic recovery material, not a backup: v1 proposals carry no note bytes, proposals disappear once canonicalized, and embedded note bytes are visible to the GUARDIAN operator (existing v2 behavior, not a new exposure).
The public-backfill strategy
Normal forward sync starts from the store's global cursor, so in a shared store the cursor may already be past blocks containing the recovered account's notes. The backfill scans a historical block range — genesis to the current chain tip by default — for public notes addressed at the account's standard note tag and imports them with their on-chain inclusion proofs, without ever touching the global sync height. The scan's cost grows with the number of matching notes, not the range length, so a full genesis-to-tip scan is fast on an ordinary account. The flow syncs the chain state before this strategy runs, so it works on a store that has never synced.
report.backfill carries the requested range (scannedFrom /
scannedTo), the number of unique tag matches (discovered), the private
matches skipped (skippedPrivate — the chain holds no body for them; the
other two strategies cover those), the matches the relevance screen
rejected (skippedIrrelevant), the matches this SDK's static screen could
not judge (skippedUnscreenable — custom note scripts; the Rust SDK's
execution-based screener judges every note and always reports 0 there),
and one NoteImportOutcome per screened-in public note. A proof-less
expected record left by an earlier proposal import is upgraded in place
with the freshly fetched proof rather than skipped. Sub-ranges the scan
could not cover land in uncovered with retryable/reason set, so a
partial scan never aborts the rest of the flow.
Preserving Notes Across a Guardian Switch
Pending proposals do not survive a guardian switch: the new GUARDIAN is
registered with bare account state, so once the client repoints, the notes
embedded in the old GUARDIAN's pending consume-notes proposals are the one
recovery source recoverNotes can no longer reach. executeProposal runs
the proposal-import slice of the flow automatically on the switch-guardian
path — against the old GUARDIAN, before the switch transaction executes —
so those notes land in the local store while they are still reachable
(issue #417). The step is best-effort and bounded by a 30s timeout with
cooperative cancellation: an unreachable or hung old GUARDIAN is warned
about, never allowed to block the switch.
The transport drain and public backfill deliberately do not run on the switch path: a switch executes against an intact local store, and both the note transport and the node are configured independently of the GUARDIAN, so a switch loses nothing they rescan. They remain device-loss recovery tools and work unchanged against the new GUARDIAN.
A wallet repointing a follower client by hand should request the same preservation before switching clients:
// Before setGuardianClient(newGuardian):
const report = await multisig.preservePreSwitchProposalNotes();
preservePreSwitchProposalNotes is the one public entry point for switch
preservation — it wraps the import slice in the switch-specific safety
contract, so there is no equivalent way to request it through
recoverNotes options. It returns the slice's NoteRecoveryReport (or
undefined when the flow could not run or timed out) and never throws.
On timeout a cancellation token stops every step and inner loop at its
next checkpoint (including between RPC retry attempts), and the switch
waits a short bounded grace for the one uninterruptible in-flight
operation to settle so it cannot overlap the switch transaction. Imported
notes are verified by the next normal sync. Like every listing-based flow
this covers proposals pending at the next nonce; one superseded at an
earlier nonce (it lost a same-nonce race) is filtered out and its embedded
notes are not imported here.
Proposal Operations
Every create*Proposal method takes a single trailing options object (issue
#387). All of them accept an optional nonce that identifies the proposal
(defaults to Date.now()); method-specific options are listed with each
method below. Passing a legacy positional nonce number where the options
object is expected throws instead of silently applying defaults.
P2ID Transfer (Send Funds)
const proposal = await multisig.createP2idProposal(
recipientAccountId, // Recipient's account ID
faucetAccountId, // Faucet (token) ID
1000n // Amount to send
);
// Private note: only the note's hash is published on chain. Pass `noteType`
// in the options object (`NoteType` comes from `@miden-sdk/miden-sdk`).
import { NoteType } from '@miden-sdk/miden-sdk';
const privateProposal = await multisig.createP2idProposal(
recipientAccountId,
faucetAccountId,
1000n,
{ noteType: NoteType.Private }, // note visibility; defaults to NoteType.Public
);
// P2IDE note (issue #366): pass `reclaimHeight` and/or `timelockHeight`
// (absolute block heights) to create a reclaimable/timelocked note instead
// of a plain P2ID note. `reclaimHeight` lets the sender reclaim the note if
// it is still unconsumed at that block; `timelockHeight` prevents
// consumption before that block.
const reclaimableProposal = await multisig.createP2idProposal(
recipientAccountId,
faucetAccountId,
1000n,
{ reclaimHeight: 500_000 },
);
Note that the two heights are independent and the SDK does not cross-check
them: a timelockHeight at or above reclaimHeight means the recipient
never has a window in which they can claim before the sender can reclaim —
usually not what you want.
Warning: a
privateP2ID note publishes only its hash on chain. The recipient cannot discover the note by syncing; the full note details must be shared with them out-of-band before they can consume it. UseexportNoteToBytes/importNoteFromBytes(or the browser file variantsexportNoteToFile/importNoteFromFile) for that transfer (issue #356):
// Sender: resolve the note ID BEFORE executing (it derives from the
// pre-execution vault state), then export after execution.
const noteId = await multisig.getP2idNoteId(privateProposal);
// ...sign + execute the proposal...
const noteFileBytes = await multisig.exportNoteToBytes(noteId);
// Deliver `noteFileBytes` to the recipient out-of-band (file, message, ...).
// Or, in a browser, trigger a download of the note file directly:
await multisig.exportNoteToFile(noteId);
// Recipient: import the bytes (or a File from an <input type="file">), then
// sync so the note's on-chain commitment is tracked; it then appears in
// getConsumableNotes() and can be consumed with createConsumeNotesProposal
// as usual.
const importedNoteId = await multisig.importNoteFromBytes(noteFileBytes);
Note: only the proposer needs the note file. The proposal embeds the note, and every cosigner device that verifies or signs it authenticates that embedded note from the node's inclusion proof before rebuilding (see Authenticated note consumption above), so the store contents of the other devices no longer matter. The proposer must wait for the note to commit on chain before proposing: an uncommitted note cannot be authenticated and proposal creation fails with
ConsumeNoteNotAuthenticatedError.
Consume Notes (Claim Received Funds)
// Get consumable notes
const notes = await multisig.getConsumableNotes();
// Create proposal to consume them
const noteIds = notes.map(n => n.id);
const proposal = await multisig.createConsumeNotesProposal(noteIds);
A private note received out-of-band must first be loaded with
importNoteFromBytes(noteFileBytes) or importNoteFromFile(file) (see the
P2ID section above); after a sync it shows up in getConsumableNotes() like
any public note.
Add Signer
const proposal = await multisig.createAddSignerProposal(
newSignerCommitment, // New signer's public key commitment
{ newThreshold }, // Options: nonce, newThreshold (defaults to current)
);
Override dilution on signer growth: per-procedure threshold overrides are absolute signature counts, and the on-chain update never re-scales them — growing the signer set silently lowers every override's effective signing ratio (a 2-of-2 override becomes 2-of-n). Both SDKs surface this: the TypeScript SDK logs a
console.warnper affected override and exposesmultisig.overridesDilutedBySignerGrowth(newNumSigners); the Rust SDK emits atracing::warn!inpropose_transactionand exposesMultisigAccount::overrides_diluted_by_signer_growth(new_num_signers). To keep the intended security level, raise the affected overrides via an update-procedure-threshold proposal alongside the growth.
Overrides apply to guardian rotation too:
update_guardianis a valid override target in both SDKs. Guardian rotation is a note-less operation, so the upstream contract skips the guardian signature check whenupdate_guardian_public_keyis the only non-auth procedure called — the multisig quorum alone authorizes it. That quorum is the override onupdate_guardian's root when one is set, so an override of 1 lets a single signer replace the guardian with no guardian consent. Nothing in the builder or the contract restricts overrides on this root; treat an override onupdate_guardianas a deliberate reduction of the account's recovery threshold. Installing such an override is gated — at creation the account's author chooses it, and at runtimeset_procedure_thresholdrequires the default quorum plus a guardian signature — but the gate applies only to installing it. Once stored, the reduced quorum governs every future rotation on its own, with no further guardian involvement, until another update-procedure-threshold proposal raises it back.
Remove Signer
const proposal = await multisig.createRemoveSignerProposal(
signerToRemove, // Signer's commitment to remove
{ newThreshold }, // Options: nonce, newThreshold (defaults to
// min(current threshold, remaining signer count))
);
Change Threshold
const proposal = await multisig.createChangeThresholdProposal(
newThreshold // New account-wide threshold, 1..=signer count
);
This moves the account-wide default threshold, the "N" in N-of-M. It is not
the per-procedure override that createUpdateProcedureThresholdProposal sets.
Where an override exists it takes precedence for the procedure it names, so
lowering the default does not lower an overridden procedure, and this call is
itself gated by whatever threshold governs update_signers. The signer set is
not a parameter: membership changes go through createAddSignerProposal and
createRemoveSignerProposal.
The Rust equivalent is TransactionType::update_signers(threshold, commitments),
passing the account's current signer set unchanged.
Switch GUARDIAN Provider
const proposal = await multisig.createSwitchGuardianProposal(
newGuardianEndpoint, // New GUARDIAN server URL
newGuardianCommitment // New GUARDIAN server's public key
);
When the current GUARDIAN is unreachable, create the switch proposal fully
offline instead — nothing is pushed to the current operator (issue #433;
mirrors the Rust create_proposal_offline). The returned ExportedProposal
already carries the proposer's signature; share its JSON with cosigners for
importProposal / signProposalOffline, then execute:
const exported = await multisig.createSwitchGuardianProposalOffline(
newGuardianEndpoint,
newGuardianCommitment
);
Signing & Executing Proposals
The two SDKs differ on who has signed a new proposal. On ordinary creation the Rust SDK attaches the proposer's signature and the TypeScript SDK does not, so the same 2-of-3 flow needs one more signature collected on the TypeScript path. The exception is
createSwitchGuardianProposalOffline, which signs as it exports and so carries the proposer's signature on both SDKs. Neither convention is wrong, but threshold arithmetic written against one SDK is wrong against the other, and arithmetic written against ordinary creation is wrong for the offline switch. Offer the proposal to every cosigner and letsignaturesCollecteddecide, rather than assuming who has already signed.
// List all pending proposals
const proposals = await multisig.syncProposals();
for (const proposal of proposals) {
if (proposal.verification.status === 'failed') {
// The signed summary could not be reproduced from the metadata (for
// example, the metadata was tampered with). Signing and executing refuse it.
const { retryable, message } = proposal.verification;
console.log(`${proposal.id}: ${retryable ? 'retry later' : 're-propose'} — ${message}`);
continue;
}
console.log(`${proposal.id}: ${proposal.status.type}`);
if (proposal.status.type === 'pending') {
console.log(` Signatures: ${proposal.status.signaturesCollected}/${proposal.status.signaturesRequired}`);
}
}
// Sign a proposal
const signed = await multisig.signProposal(proposalId);
// Execute when ready
if (signed.status.type === 'ready') {
await multisig.executeProposal(proposalId);
}
Offline Export/Import
// Export proposal for offline signing
const json = multisig.exportProposalToJson(proposalId);
// Share via file, QR code, etc.
// On the cosigner's machine: import and sign. Needs a synced store
// unless this is a switch_guardian proposal.
const imported = multisig.importProposal(json);
const signedJson = multisig.signProposalOffline(proposalId);
// Back on online machine: import signed proposal
const signedProposal = multisig.importProposal(signedJson);
await multisig.executeProposal(signedProposal.id);
Recovering Accounts By Key
Use recoverByKey when a wallet has a signing key from an account's
authorization set but does not know the account ID yet. The helper queries
Guardian's /state/lookup endpoint with proof-of-possession of the key,
fetches state for each matching account, and returns (accountId, state)
pairs.
const recovered = await client.recoverByKey(signer);
if (recovered.length === 0) {
console.log('No account on this Guardian authorizes this key');
}
for (const { accountId, state } of recovered) {
console.log('Recovered account:', accountId);
console.log('State commitment:', state.commitment);
const multisig = await client.load(accountId, signer);
// Continue with normal proposal or sync flows.
}
The signer passed to recoverByKey must implement signLookupMessage. The
bundled FalconSigner, EcdsaSigner, Miden Wallet signer, and Para signer
support it. Multiple matches are valid: the same key commitment may authorize
more than one account, and the method returns all matches instead of choosing
one implicitly.
API Reference
MultisigClient
| Method | Description |
|---|---|
create(config, signer) | Create new multisig account |
load(accountId, signer) | Load existing account from GUARDIAN |
recoverByKey(signer) | Discover accounts that authorize the signer's key and fetch each current state |
guardianClient | Access to underlying GUARDIAN HTTP client |
Multisig
| Method | Description |
|---|---|
accountId | Get account ID (hex string) |
threshold | Get current threshold |
signerCommitments | Get list of signer commitments |
fetchState() | Fetch latest state from GUARDIAN |
syncState() | Reconcile the local store with GUARDIAN; pre-checks GUARDIAN's canonical nonce (getCanonicalNonce) and skips the state fetch when that nonce is below the local nonce, or equal to it at the same commitment. Returns { source: 'guardian', state } or { source: 'local', localNonce, guardianNonce } |
registerOnGuardian() | Register new account with GUARDIAN |
syncProposals() | Sync proposals from GUARDIAN, pruning ones it no longer reports (TS-only cache reconciliation; the Rust list_proposals builds a fresh list per call and has no cache to prune) |
abandonCandidate(nonce) | Record an abandon intent for a stuck candidate (worker resolves after a short quarantine) |
abandonStatus(nonce) | Poll the abandon resolution: waiting / landed / abandoned / unexpected |
deltaHistory({ limit?, cursor? }?) | One page of canonical delta history, newest-first, with decoded note summaries |
listProposals() | Get proposals from the most recent sync plus not-yet-reported local creates/imports |
createP2idProposal(recipient, faucet, amount, { nonce, noteType, reclaimHeight, timelockHeight }?) | Create transfer proposal (noteType: NoteType.Public (default) or NoteType.Private; presence of reclaimHeight/timelockHeight creates a P2IDE note, issue #366) |
createConsumeNotesProposal(noteIds, { nonce }?) | Create note consumption proposal |
getP2idNoteId(proposal) | Compute the note ID a P2ID proposal creates (call before executing) |
exportNoteToBytes(noteId) | Export a created note as note-file bytes for out-of-band delivery |
exportNoteToFile(noteId, filename?) | Browser-only: download the note file |
importNoteFromBytes(noteBytes) | Import a note file received out-of-band |
importNoteFromFile(file) | Import a note file from a browser File/Blob |
recoverNotes(options?) | Run the note-recovery strategies (transport drain, proposal import, public backfill) as one flow with a final verifying sync; returns a combined NoteRecoveryReport |
preservePreSwitchProposalNotes() | Pre-switch slice of the flow (issue #417): import notes embedded in the old GUARDIAN's pending proposals before repointing; run automatically by executeProposal on the switch path; returns the report or undefined |
createAddSignerProposal(commitment, { nonce, newThreshold }?) | Create add signer proposal (newThreshold defaults to the current threshold) |
createRemoveSignerProposal(commitment, { nonce, newThreshold }?) | Create remove signer proposal (newThreshold defaults to min of current threshold and remaining signer count) |
createChangeThresholdProposal(threshold, { nonce }?) | Change the account-wide threshold (not a per-procedure override) |
createUpdateProcedureThresholdProposal(procedure, threshold, { nonce }?) | Create per-procedure threshold override proposal (threshold: 0 clears the override) |
createSwitchGuardianProposal(endpoint, pubkey, { nonce }?) | Create GUARDIAN switch proposal |
createSwitchGuardianProposalOffline(endpoint, pubkey, { nonce }?) | Create GUARDIAN switch proposal without contacting the current GUARDIAN; returns a signed ExportedProposal for side-channel cosigning (issue #433) |
createCustomProposal(requestBytes, label, { nonce }?) | Create a producer-built custom proposal (issue #266) |
signProposal(id) | Sign a proposal |
executeProposal(id) | Execute ready proposal |
exportProposalToJson(id) | Export for offline signing |
importProposal(json) | Import offline proposal |
signProposalOffline(id) | Sign imported proposal offline |
getConsumableNotes() | Get notes that can be consumed |
getSignerPublicKeyCommitments() | Read the current signer public-key commitments from account storage, ordered by signer index (strict; throws on partial reads) |
getGuardianPublicKeyCommitment() | Read the current guardian commitment from account storage (strict; throws when the entry is missing — the guarded-multisig always includes a guardian) |
FalconSigner
| Property/Method | Description |
|---|---|
commitment | Public key commitment (hex) |
publicKey | Serialized public key (hex) |
signRequest(id, timestamp, requestPayload) | Sign account ID + timestamp + request payload digest for auth |
signCommitment(hex) | Sign commitment/word |
signLookupMessage(timestamp, keyCommitment) | Sign account-less lookup digest for recoverByKey |
AccountInspector
| Method | Description |
|---|---|
fromBase64(data) | Inspect base64-encoded account |
fromAccount(account) | Inspect Account object |
getSignerPublicKeyCommitments(account) | Read the signer public-key commitments ordered by signer index (strict; throws on a foreign contract version or any absent entry) |
getGuardianPublicKeyCommitment(account) | Read the guardian commitment (strict; throws on a foreign contract version or a missing entry) |
fromBase64 / fromAccount return DetectedMultisigConfig:
threshold: numbernumSigners: numbersignerCommitments: string[]guardianCommitment: string | nullvaultBalances: { faucetId, amount }[]
Reading an account's keys: since the account uses the upstream
AuthGuardedMultisigcomponent, the Miden SDK'sAccount.getPublicKeyCommitments()returns the approver commitments natively. The accessors above are the strict, layout-insulated alternative (issue #306): they validate the complete set against the configured signer count and throw instead of silently omitting unreadable entries, and they shield consumers from storage-layout changes across contract versions (both are gated on the pinned contract version — see Contract version pinning). Commitments are ordered by signer index as currently stored (indices re-pack when signers are removed); hot/cold roles are a consumer-side convention. TheAccountmust come from the same copy of@miden-sdk/miden-sdkthat this package links — a separately bundled SDK copy is rejected by the SDK's instance checks.
Rust SDK Guide
Installation & Setup
use miden_multisig_client::{
MultisigClient, MultisigClientBuilder,
MultisigAccount, TransactionType,
Proposal, ProposalStatus,
KeyManager, GuardianKeyStore,
Endpoint, Word, AccountId, SecretKey,
};
// Build client with fluent API
let mut client = MultisigClient::builder()
.miden_endpoint(Endpoint::new("http://localhost:57291"))
.guardian_endpoint("http://localhost:50051")
.account_dir("/tmp/multisig-data")
.generate_key() // Or: .with_secret_key(key)
.build()
.await?;
Creating Accounts
// Collect signer commitments (your key + cosigners)
let signer_commitments = vec![
client.user_commitment(), // Your commitment
commitment_from_hex("0x1234...")?, // Cosigner 1
commitment_from_hex("0x5678...")?, // Cosigner 2
];
// Create 2-of-3 multisig
let account = client.create_account(2, signer_commitments).await?;
// Register with GUARDIAN
client.push_account().await?;
println!("Created account: {}", account.id());
println!("Threshold: {}", account.threshold()?);
println!("Signers: {:?}", account.cosigner_commitments_hex());
Loading Existing Accounts
// Pull account from GUARDIAN (as a cosigner)
let account = client.pull_account(account_id).await?;
// Sync with the Miden network, then with GUARDIAN. The GUARDIAN step
// asks for the canonical nonce first and fetches the full state only
// when GUARDIAN is ahead of the local account or has diverged from it
// (same nonce, different commitment).
client.sync().await?;
// Inspect account
println!("Threshold: {}", account.threshold()?);
println!("Nonce: {}", account.nonce());
println!("GUARDIAN commitment: {:?}", account.guardian_commitment()?);
Unlike the TypeScript load(), pull_account overwrites the local store with
GUARDIAN's state unconditionally, by design: it is for joining an account or
discarding a store known to be bad. Use sync_from_guardian to refresh an
account you intend to keep.
Delta History
Guardian retains the account's canonical delta history, allowing a wallet to
render its history after recovery. delta_history() returns one
HistoryPage at a time, newest-first by nonce, with server-decoded input and
output note summaries (typed tags, visibility, assets, counterparties). Every
entry carries HistoryEntryStatus::Canonical today; the set widens if the
feed gains a status filter.
let mut cursor: Option<String> = None;
loop {
let page = client.delta_history(Some(50), cursor.take()).await?;
for entry in &page.entries {
println!("nonce {} at {}", entry.nonce, entry.timestamp);
for note in &entry.output_notes {
println!(" sent {} note {}", note.tag.as_str(), note.note_id);
}
}
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
limit accepts 1–500 (server default 50 when None). Only canonical
(confirmed) transactions appear — pending proposals live on
list_proposals(). The feed is served even while the account is
paused. Guardian only ever sees transactions pushed through it, so
history of transactions the account executed elsewhere is not included.
Output notes whose full details are not in the stored summary (e.g.
private notes carried as partial notes) appear with tag custom and no
recipient.
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, so a caller cannot accidentally skip the context
they need (a tracked account, a synced store) or the final verifying sync.
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 {
// A strategy that could not run at all lands here; the flow continues
// with the remaining strategies either way.
eprintln!("step {} did not run: {}", problem.step, problem.reason);
}
if report.retryable {
// The flow is idempotent — rerun it to plausibly recover more.
}
Strategies are individually selectable and the backfill's block range can be bounded:
use miden_multisig_client::{NoteRecoveryOptions, PublicBackfillOptions};
// Rescan only the transport backlog and recent public history, skipping the
// proposal import and the final verifying sync.
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, plus the problems
list, synced, the total imported count, and an aggregate retryable
flag.
The transport-drain strategy
A fresh store has no note-transport cursor — and in a store shared with other accounts, sync may have advanced the cursor past private notes addressed to the recovered account. The drain rescans the full transport backlog for every tracked note tag (running as many transport syncs as the upstream per-sync tag backfill cap requires), regardless of the stored cursor, and never regresses it. It is idempotent, and a failed drain restores the covered-tags bookkeeping so normal sync keeps working exactly as before the attempt.
Transport problems land in report.transport, never as errors:
Unavailable means no transport endpoint is configured or the transport
could not be reached before anything was imported; Failed with
retryable: true keeps any partial progress and rerunning continues it.
Not a backup: transport recovery is bounded by the transport service's
retention. Senders may deliver private notes out-of-band without using the
transport, and relayed blobs are pruned after the retention window. Notes
outside both are recoverable only from their sender (see
import_note_from_file).
The proposal-import strategy
v2 consume_notes proposals embed the serialized notes they consume, so
pending proposals are opportunistic recovery material: the strategy lists
them from GUARDIAN (isolating per-proposal parse or binding failures as
Invalid outcomes instead of failing the whole step), validates each
embedded note against the proposal's declared note ids, fetches on-chain
inclusion proofs, and imports per note — so it works for private notes too;
the node never needs to hold the note body. Binding verification itself
already authenticates a proposal's embedded notes into the store (see
Authenticated note consumption), so a proposal that verified reports its
notes as already present here; the import step still covers notes whose
proposal failed verification for an unrelated reason.
Each unique embedded note gets a NoteImportOutcome in
report.proposal_import (Imported, AlreadyPresent, AlreadyConsumed,
NotCommitted, Invalid, or Failed); duplicates across proposals fold
into one outcome and no per-note problem blocks the others. A note not yet
on chain is recorded in Expected state with its sync-hint tag so a later
sync picks it up, and an import whose inclusion proof fails verification
against the authenticated chain is demoted to Failed rather than counted
as recovered.
Proposals are opportunistic recovery material, not a backup: v1 proposals carry no note bytes, proposals disappear once canonicalized, and the embedded bytes are visible to the GUARDIAN operator (existing v2 behavior, not a new exposure).
The public-backfill strategy
Normal forward sync starts from the store's global cursor, so in a shared store the cursor may already be past blocks containing the recovered account's notes. The backfill scans a historical block range — genesis to the current chain tip by default — for public notes addressed at the account's standard note tag and imports them with their on-chain inclusion proofs, without ever touching the global sync height. The scan's cost grows with the number of matching notes, not the range length, so a full genesis-to-tip scan is fast on an ordinary account. The flow syncs the chain state before this strategy runs, so it works on a store that has never synced.
report.backfill carries the requested range (scanned_from /
scanned_to), the number of unique tag matches (discovered), the private
matches skipped (skipped_private — the chain holds no body for them; the
other two strategies cover those), the matches the execution-based
NoteScreener rejected (skipped_irrelevant — tags are best-effort,
truncated filters, and exactly like normal sync only notes the account can
actually consume are imported), and one NoteImportOutcome per screened-in
public note (skipped_unscreenable exists for cross-SDK parity and is
always 0 here). A proof-less expected record left by an earlier proposal
import is upgraded in place with the freshly fetched proof rather than
skipped. Sub-ranges the scan could not cover land in uncovered with
retryable/reason set, so a partial scan never aborts the rest of the
flow.
Preserving Notes Across a Guardian Switch
Pending proposals do not survive a guardian switch: the new GUARDIAN is
registered with bare account state, so once the client repoints, the notes
embedded in the old GUARDIAN's pending consume-notes proposals are the one
recovery source recover_notes can no longer reach. execute_proposal
runs the proposal-import slice of the flow automatically on the
switch-guardian path — against the old GUARDIAN, before the switch
transaction executes — so those notes land in the local store while they
are still reachable (issue #417). The step is best-effort and bounded by a
30s timeout: an unreachable or hung old GUARDIAN is warned about, never
allowed to block the switch.
The transport drain and public backfill deliberately do not run on the
switch path: a switch executes against an intact local store, and both the
note transport and the node are configured independently of the GUARDIAN,
so a switch loses nothing they rescan. They remain device-loss recovery
tools and work unchanged against the new GUARDIAN. The offline switch flow
(execute_imported_proposal) also skips the import — it exists to avoid
contacting the GUARDIAN at all — so when the old GUARDIAN is in fact still
reachable, run the preservation explicitly before executing offline.
A wallet repointing a follower client by hand should request the same
preservation before set_guardian_endpoint:
// Before set_guardian_endpoint(new_endpoint, true):
let report = client.preserve_pre_switch_proposal_notes().await;
preserve_pre_switch_proposal_notes is the one public entry point for
switch preservation — it wraps the import slice in the switch-specific
safety contract (timeout, cancellation, warnings), so there is no
equivalent way to request it through recover_notes options. It returns
the slice's NoteRecoveryReport (None when the flow could not run or
timed out) and never errors; on timeout the in-flight flow is cancelled
and the switch proceeds. Imported notes are verified by the next normal
sync. Like every listing-based flow this covers proposals pending at the
next nonce; one superseded at an earlier nonce (it lost a same-nonce
race) is filtered out and its embedded notes are not imported here.
Transaction Types
// P2ID Transfer (public note)
let tx = TransactionType::transfer(recipient_id, faucet_id, 1000);
// P2ID Transfer with a private note (only the note hash is published on
// chain; the recipient needs the note shared out-of-band — see
// "Out-of-Band Note Transfer" below). `NoteType` is re-exported from
// `miden_protocol::note`.
let tx = TransactionType::transfer_with_note_type(
recipient_id, faucet_id, 1000, NoteType::Private,
);
// P2IDE Transfer (issue #366): optional reclaim and/or timelock block
// heights. Presence of either height creates a P2IDE note instead of a
// plain P2ID note. `P2ideHeights` uses `NonZeroU32`, so the invalid zero
// height is unrepresentable.
use std::num::NonZeroU32;
use miden_multisig_client::P2ideHeights;
let tx = TransactionType::transfer_p2ide(
recipient_id, faucet_id, 1000, NoteType::Public,
P2ideHeights { reclaim: NonZeroU32::new(500_000), timelock: None },
);
// Consume Notes
let tx = TransactionType::consume_notes(vec![note_id1, note_id2]);
// Add Cosigner
let tx = TransactionType::add_cosigner(new_commitment);
// Remove Cosigner
let tx = TransactionType::remove_cosigner(commitment_to_remove);
// Update Signers (change threshold and/or signer set)
let tx = TransactionType::update_signers(new_threshold, new_signer_list);
// Switch GUARDIAN Provider
let tx = TransactionType::switch_guardian(new_endpoint, new_commitment);
Proposal Operations
// Create and submit proposal
let proposal = client.propose_transaction(tx).await?;
println!("Proposal ID: {}", proposal.id);
// Or with offline fallback
match client.propose_with_fallback(tx).await? {
ProposalResult::Online(proposal) => {
println!("Submitted to GUARDIAN: {}", proposal.id);
}
ProposalResult::Offline(exported) => {
// Save for file-based sharing
std::fs::write("proposal.json", exported.to_json()?)?;
}
}
Listing & Signing Proposals
// List pending proposals
let proposals = client.list_proposals().await?;
for proposal in &proposals {
if let ProposalVerification::Failed { retryable, message } = &proposal.verification {
// The signed summary could not be reproduced from the metadata (for
// example, the metadata was tampered with). Signing and executing refuse it.
let hint = if *retryable { "retry later" } else { "re-propose" };
println!("{}: {hint} — {message}", proposal.id);
continue;
}
match &proposal.status {
ProposalStatus::Pending => {
let (signatures_collected, signatures_required) = proposal.signature_counts();
println!("{}: {}/{} signatures",
proposal.id, signatures_collected, signatures_required);
println!(" Signed by: {:?}", proposal.metadata.signers);
}
ProposalStatus::Ready => {
println!("{}: Ready to execute", proposal.id);
}
ProposalStatus::Finalized => {
println!("{}: Already executed", proposal.id);
}
}
}
// Sign a proposal
client.sign_proposal(&proposal_id).await?;
// Execute when ready
client.execute_proposal(&proposal_id).await?;
Offline Export/Import
// Create offline proposal (when GUARDIAN unavailable)
let exported = client.create_proposal_offline(tx).await?;
std::fs::write("proposal.json", exported.to_json()?)?;
// On the cosigner's machine: load and sign. Needs a synced store
// unless this is a switch_guardian proposal.
let json = std::fs::read_to_string("proposal.json")?;
let mut exported: ExportedProposal = serde_json::from_str(&json)?;
client.sign_imported_proposal(&mut exported)?;
std::fs::write("signed.json", exported.to_json()?)?;
// Back online: execute
let json = std::fs::read_to_string("signed.json")?;
let exported: ExportedProposal = serde_json::from_str(&json)?;
client.execute_imported_proposal(&exported).await?;
Note Filtering
use miden_multisig_client::NoteFilter;
// List all consumable notes
let notes = client.list_consumable_notes().await?;
// Filter by faucet
let filter = NoteFilter::by_faucet(faucet_id);
let notes = client.list_consumable_notes_filtered(filter).await?;
// Filter by faucet with minimum amount
let filter = NoteFilter::by_faucet_min_amount(faucet_id, 5000);
let notes = client.list_consumable_notes_filtered(filter).await?;
for note in notes {
println!("Note {}: {} tokens", note.id, note.amount_for_faucet(faucet_id));
}
Out-of-Band Note Transfer (Private Notes)
A private P2ID note publishes only its commitment on chain, so the recipient's client can never learn the note contents via sync. The sender must export the note and deliver the file out-of-band (issue #356):
// Sender: resolve the note ID BEFORE executing (it derives from the
// pre-execution vault state), then export after execution.
let note_id = client.p2id_note_id(&proposal)?;
client.execute_proposal(&proposal.id).await?;
client.export_note_to_file(¬e_id.to_hex(), Path::new("note.mno")).await?;
// Deliver note.mno to the recipient out-of-band (file, message, ...).
// Recipient: import the file, then sync so the note's on-chain commitment
// is tracked; it then appears in list_consumable_notes() and can be
// consumed with a regular consume-notes proposal.
let imported_note_id = client.import_note_from_file(Path::new("note.mno")).await?;
client.sync().await?;
export_note_to_bytes / import_note_from_bytes are the in-memory variants
for programmatic delivery.
Every cosigner device that verifies or signs the consume-notes proposal needs
the note in its local store with the on-chain inclusion proof — deliver the
note file to each of them (import + sync), not just to the proposer. A
cosigner whose store lacks the authenticated note rebuilds the transaction
differently (the input-notes commitment distinguishes authenticated from
unauthenticated consumption) and rejects the proposal with metadata does not match tx_summary. The sender's own device heals itself: it already knows the
full note, so a post-commit sync is enough.
Recovering Accounts By Key
Use recover_by_key when the configured signer is known but the account ID is
not. The client signs a lookup-bound authentication message, asks Guardian for
accounts that authorize the signer's commitment, fetches state for each match,
and returns RecoveredAccount values.
let recovered = client.recover_by_key().await?;
if recovered.is_empty() {
println!("No account on this Guardian authorizes this key");
}
for entry in recovered {
println!("Recovered account: {}", entry.account_id);
println!("State commitment: {}", entry.state.commitment);
let account_id = AccountId::from_hex(&entry.account_id)?;
client.pull_account(account_id).await?;
// Continue with normal proposal or sync flows.
}
An empty list means the key is valid but this Guardian has no account metadata
that authorizes its commitment. Authentication failures, malformed lookup
responses, and per-account get_state failures are returned as errors.
API Reference
MultisigClient
| Method | Description |
|---|---|
builder() | Create builder for configuration |
create_account(threshold, commitments) | Create new multisig |
pull_account(id) | Join existing multisig |
push_account() | Register account with GUARDIAN |
sync() | Sync with Miden network |
account() | Get loaded account (Option) |
account_id() | Get account ID (Option) |
user_commitment() | Get user's key commitment |
user_commitment_hex() | Get commitment as hex |
recover_by_key() | Discover accounts that authorize the configured signer and fetch each current state |
propose_transaction(tx) | Create and submit proposal |
propose_with_fallback(tx) | Online or offline proposal |
list_proposals() | List pending proposals |
sign_proposal(id) | Sign a proposal |
execute_proposal(id) | Execute ready proposal |
abandon_candidate(nonce) | Record an abandon intent for a stuck candidate (worker resolves after a short quarantine) |
abandon_status(nonce) | Poll the abandon resolution: Waiting / Landed / Abandoned / Unexpected |
delta_history(limit, cursor) | One page of canonical delta history, newest-first, with decoded note summaries |
create_proposal_offline(tx) | Create offline proposal |
sign_imported_proposal(exported) | Sign offline proposal |
execute_imported_proposal(exported) | Execute offline proposal |
export_proposal(id, path) | Export to file |
import_proposal(path) | Import from file |
list_consumable_notes() | List available notes |
list_consumable_notes_filtered(filter) | Filter notes |
p2id_note_id(proposal) | Compute the note ID a P2ID proposal creates (call before executing) |
export_note_to_file(note_id, path) | Export a created note to a file for out-of-band delivery |
export_note_to_bytes(note_id) | Export a created note as note-file bytes |
import_note_from_file(path) | Import a note file received out-of-band |
import_note_from_bytes(bytes) | Import a note from note-file bytes |
recover_notes(options) | Run the note-recovery strategies (transport drain, proposal import, public backfill) as one flow with a final verifying sync; returns a combined NoteRecoveryReport |
preserve_pre_switch_proposal_notes() | Pre-switch slice of the flow (issue #417): import notes embedded in the old GUARDIAN's pending proposals before repointing; run automatically by execute_proposal on the switch path; returns Option<NoteRecoveryReport> |
MultisigAccount
| Method | Description |
|---|---|
id() | Account ID |
nonce() | Current nonce |
commitment() | Account state commitment |
threshold() | Signing threshold |
num_signers() | Number of signers |
cosigner_commitments() | List of commitments (Word) |
cosigner_commitments_hex() | List as hex strings |
is_cosigner(commitment) | Check if commitment is signer |
guardian_commitment() | GUARDIAN server commitment |
TransactionType
| Variant | Description |
|---|---|
P2ID { recipient, faucet_id, amount, note_type, heights } | Transfer funds (note_type selects note visibility; heights: P2ideHeights with either constraint set creates a P2IDE note — use transfer_p2ide(); transfer() defaults to a public plain-P2ID note) |
ConsumeNotes { note_ids } | Consume notes |
AddCosigner { new_commitment } | Add signer |
RemoveCosigner { commitment } | Remove signer |
UpdateSigners { new_threshold, signer_commitments } | Update config |
SwitchGuardian { new_endpoint, new_commitment } | Switch GUARDIAN |
ProposalStatus
| Variant | Description |
|---|---|
Pending | Collecting sigs (proposal.signature_counts(), proposal.metadata.signers) |
Ready | Threshold met |
Finalized | Executed |
Use Cases
Use Case 1: Treasury Management (2-of-3)
A company treasury requiring 2 of 3 executives to approve transfers.
// Setup: CEO, CFO, and COO each have their own signer
const config = {
threshold: 2,
signerCommitments: [ceoCommitment, cfoCommitment, cooCommitment],
guardianCommitment,
};
const treasury = await client.create(config, ceoSigner);
await treasury.registerOnGuardian();
// CEO proposes payment to vendor
const payment = await treasury.createP2idProposal(
vendorAccountId,
usdcFaucetId,
50000n
);
// CFO reviews and signs
const cfoMultisig = await cfoClient.load(treasury.accountId, cfoSigner);
await cfoMultisig.syncProposals();
await cfoMultisig.signProposal(payment.id);
// Payment executes (threshold met: CEO + CFO = 2)
await treasury.executeProposal(payment.id);
Use Case 2: Secure Operations (3-of-5)
High-security operations requiring 3 of 5 board members.
// Create 3-of-5 multisig
let board_commitments = vec![member1, member2, member3, member4, member5];
let account = client.create_account(3, board_commitments).await?;
// Propose removing a compromised member
let tx = TransactionType::remove_cosigner(compromised_member);
let proposal = client.propose_transaction(tx).await?;
// Three members must sign
// member1.sign_proposal(...)
// member2.sign_proposal(...)
// member3.sign_proposal(...)
// Execute with 3 signatures
client.execute_proposal(&proposal.id).await?;
Use Case 3: Note Consumption
Claiming tokens sent to the multisig.
// Check for incoming notes
const notes = await multisig.getConsumableNotes();
console.log('Pending notes:');
for (const note of notes) {
for (const asset of note.assets) {
if (asset.isFungible()) {
console.log(` ${note.id}: ${asset.amount()} from faucet ${asset.faucetId()}`);
}
}
}
// Create proposal to consume all notes
const noteIds = notes.map(n => n.id);
const proposal = await multisig.createConsumeNotesProposal(noteIds);
// After threshold signatures...
await multisig.executeProposal(proposal.id);
console.log('Notes consumed, funds now in vault');
Offline Workflow
Complete Flow Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ OFFLINE SIGNING FLOW │
└─────────────────────────────────────────────────────────────────────┘
PROPOSER (Online) COSIGNER (Off-channel) EXECUTOR (Online)
───────────────── ──────────────────── ────────────────
│ │ │
│ create_proposal_offline() │ │
│ or propose_with_fallback() │ │
▼ │ │
┌──────────┐ │ │
│ Export │ │ │
│ proposal │ │ │
│ .json │─────── USB ─────────►│ │
└──────────┘ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Import JSON │ │
│ │ Sign offline │ │
│ │ Export JSON │ │
│ └──────────────┘ │
│ │ │
│ │─────── USB ───────────────►│
│ │ ▼
│ │ ┌─────────────┐
│ │ │ Import JSON │
│ │ │ Verify sigs │
│ │ │ Execute tx │
│ │ └─────────────┘
▼ ▼ ▼
Export Format (JSON)
{
"version": 1,
"account_id": "0x7925bdcc9c4df01068e79d4c94beeb",
"id": "0xabcd1234...",
"nonce": 5,
"tx_summary": {
"...": "transaction summary JSON"
},
"signatures": [
{
"signer_commitment": "0x1234...",
"signature": "0x5678..."
}
],
"signatures_required": 2,
"metadata": {
"proposal_type": "add_signer",
"salt_hex": "0x...",
"new_threshold": 2,
"signer_commitments_hex": ["0x...", "0x..."]
}
}
Version Compatibility
Which Miden protocol line each Guardian release targets, the exact miden-client
and @miden-sdk/miden-sdk pins, what broke between lines, and which upgrades
reset stored data: see
MIDEN_COMPATIBILITY.md.
Guardian's version and Miden's are not aligned (Guardian 0.16.x runs on Miden 0.15; Miden 0.16 lands in Guardian 0.17.x; Miden 0.17 lands in Guardian 0.18.x), so read that matrix rather than matching the numbers. Per-release breaking changes are also in the GitHub release notes.
Contract version pinning
Accounts are built from the audited upstream AuthGuardedMultisig component, pinned
exactly in both SDKs so a TypeScript-built account is byte-identical to a Rust-built
one:
- Rust: the
miden-standardspin in the workspaceCargo.toml - TypeScript: the
@miden-sdk/miden-sdkpin, whose bundled WASM embeds the matching upstreammiden-standardsguarded-multisig component
Matching versions is what makes the roots match, because neither SDK compiles the auth
component any more: both ask miden-standards for its AuthGuardedMultisig and use what
it returns. That settles the linkage question that used to matter here. auth_tx calls
miden::standards::fee, so its root depends on whether the standards package is linked
statically (the callee's MAST is inlined) or dynamically (an external reference is left) —
compiling an equivalent source locally links dynamically and roots differently, which is
why a locally built component could not be classified by AccountComponentInterface::from_procedures.
Taking the component from upstream removes the choice, so the pinned roots describe upstream's
build and nothing else. The request builders set the three-word multisig auth args themselves.
The exact versions for each Guardian release are in
MIDEN_COMPATIBILITY.md; they are not
repeated here so there is one place to update.
The pins are deliberate and must move together: nothing at build time verifies the
npm SDK's embedded miden-standards matches the Rust pin — the CI parity gates
(procedure_roots_match_upstream_component, the vitest procedure-roots test, and
the Playwright determinism spec) are what catch drift.
Deployed accounts are immutable. An account's code — and therefore its procedure
roots — is fixed at creation. The SDK's hardcoded PROCEDURE_ROOTS /
ProcedureName::root() values, and the transaction scripts it compiles against the
bundled library, all assume the account was built from the currently pinned
contract version. Consequences of bumping the pin to a miden-standards release whose
MASM changed:
- Management transactions built by the new SDK fail against old accounts (the script calls a procedure root the old account's code does not export).
- Per-procedure threshold reads and
set_procedure_thresholdwrites key the account'sprocedure_thresholdsstorage map by the new roots — against an old account they silently miss the stored overrides or store overrides the account never consults.
Release policy until a contract-version registry lands: treat any miden-standards / @miden-sdk pin bump that changes procedure roots as a breaking release. Bump the minor version, regenerate the root constants (both SDKs), and state explicitly in the release notes that the new SDK operates only accounts created with the new contract version. The planned fix is a version registry keyed by the account's auth-procedure root, letting one SDK operate accounts from every supported contract version.
SDK ↔ contract version support
An SDK release operates only accounts created with its pinned contract version;
the mapping is in
MIDEN_COMPATIBILITY.md.
Compatibility there is about the on-chain account, not Guardian's stored state.
Adopting a new Miden line has three times required an irreversible server-side reset, so
even an account whose contract version still matches must be re-registered
afterwards. See
MIDEN_COMPATIBILITY.md.
Both SDKs enforce this at runtime rather than trusting the table: before any
procedure-root-keyed storage read, the account's code is checked for the pinned
contract version's auth procedure (auth_tx_guarded_multisig). A mismatch fails
loudly — Rust MultisigError::UnsupportedContractVersion (from
MultisigAccount::procedure_threshold and everything built on it), TS an
unsupported contract version error from AccountInspector.fromAccount — instead
of silently reporting wrong thresholds. MultisigAccount::is_pinned_contract_version()
exposes the check directly. When a new contract version is adopted, add a row here
and regenerate the root constants in the same change.
Releasing
Steps for publishing a new version of the SDK (Rust crates + TypeScript packages).
Pre-Release Checklist
- All tests pass:
# Rust
cargo test --locked \
-p guardian-shared \
-p guardian-client \
-p miden-confidential-contracts \
-p miden-multisig-client
# TypeScript
cd packages
npm ci
npm test -w @openzeppelin/guardian-client
npm test -w @openzeppelin/guardian-evm-client
npm test -w @openzeppelin/guardian-operator-client
npm run build -w @openzeppelin/guardian-client
npm test -w @openzeppelin/miden-multisig-client
- TypeScript packages build cleanly:
cd packages
npm run build -w @openzeppelin/guardian-client
npm run build -w @openzeppelin/guardian-evm-client
npm run build -w @openzeppelin/guardian-operator-client
npm run build -w @openzeppelin/miden-multisig-client
-
Version numbers are updated in all files (see below).
-
The coordinated Rust publication archive passes:
cargo publish --dry-run --locked \
-p guardian-shared \
-p guardian-client \
-p miden-confidential-contracts \
-p miden-multisig-client
Version Bump
Update the version in these files:
| File | Field | Inherits |
|---|---|---|
Cargo.toml (workspace root) | [workspace.package] version | shared, client, contracts, miden-multisig-client |
crates/contracts/Cargo.toml | guardian-shared dep version | - |
crates/client/Cargo.toml | guardian-shared dep version | - |
crates/miden-multisig-client/Cargo.toml | guardian-client, guardian-shared, miden-confidential-contracts dep versions | - |
packages/guardian-client/package.json | version | - |
packages/guardian-evm-client/package.json | version | - |
packages/guardian-operator-client/package.json | version | - |
packages/miden-multisig-client/package.json | version + @openzeppelin/guardian-client dep version | - |
packages/package-lock.json | workspace lockfile | refresh with npm install in packages/ |
The server, miden-rpc-client, miden-keystore, and example crates have their own independent versions and are not published.
After bumping TypeScript versions, refresh the workspace lockfile:
cd packages
npm install
The lockfile records a workspace link for @openzeppelin/guardian-client, not an npm tarball, so it does not need the new client version to exist on the registry.
Publishing Rust Crates
Rust crates are published by the
Publish Rust Crates workflow.
Publishing a GitHub Release automatically selects all four crates, validates
that the tag is v followed by their coordinated version, requests one
release environment approval, and uses crates.io trusted publishing. Cargo
receives all selected packages in one command and handles dependency-safe
publication.
Configure a crates.io trusted publisher for each crate before the first automated publication:
GitHub owner: OpenZeppelin
Repository: guardian
Workflow: publish-crates.yml
Environment: release
Use a manual dry run to validate one or more selected crates without requesting approval or credentials:
gh workflow run publish-crates.yml \
--ref main \
-f dry-run=true \
-f guardian-shared=true \
-f guardian-client=true \
-f miden-confidential-contracts=true \
-f miden-multisig-client=true
A manual run with dry-run=false publishes only the selected crates after one
approval. If an unselected internal prerequisite is not already visible at the
exact coordinated version, validation fails before publication.
Publication is rerunnable. Exact versions already on crates.io are reported as already published and omitted from the Cargo command. If a publication is interrupted, rerun it; the preflight skips versions that reached crates.io and Cargo processes the remainder. Trusted-publishing failure fails closed; the workflow has no registry-token fallback.
Publishing TypeScript Packages
Packages live in the packages/ npm workspace. Install once from that directory, then publish in dependency order. miden-multisig-client links the in-repo @openzeppelin/guardian-client; the published package.json still carries the ^ version range for consumers.
cd packages
npm ci
# 1. Build TypeScript packages
npm run build -w @openzeppelin/guardian-client
npm run build -w @openzeppelin/guardian-evm-client
npm run build -w @openzeppelin/guardian-operator-client
npm run build -w @openzeppelin/miden-multisig-client
# 2. Publish base clients first (no internal deps)
npm publish -w @openzeppelin/guardian-client --access public
npm publish -w @openzeppelin/guardian-evm-client --access public
npm publish -w @openzeppelin/guardian-operator-client --access public
# 3. Publish miden-multisig-client (depends on guardian-client)
npm publish -w @openzeppelin/miden-multisig-client --access public
Publishing a GitHub release runs the Publish NPM Packages workflow for all
four packages. A manual workflow run can select any subset of the four package
inputs; at least one package must be selected. The dry-run input applies only
to that selected subset. All selected packages run serially in one protected
release environment job, so the workflow requires one approval.
Post-Release
- Create a draft GitHub Release for review:
gh release create v<version> --generate-notes --draft
- Publish the draft when the coordinated Rust, TypeScript, and server-image release workflows should start:
gh release edit v<version> --draft=false
- Approve the
releaseenvironment jobs that should publish. The Rust workflow summary records the source SHA, coordinated version, authentication mode, and ordered per-crate outcomes without credentials.