Skip to main content
Version: 0.17 (unstable)

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.md for the trust model and state/delta lifecycle, and docs/architecture/services.md for 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/miden-multisig-client @miden-sdk/[email protected]

Rust (Cargo.toml)

[dependencies]
miden-multisig-client = "0.17.0"
miden-client = "=0.16.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
const midenClient = await MidenClient.createDevnet();
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!');

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',
});

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 Falcon 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

Chain-anchored execution​

Since Miden protocol 0.16 a signed transaction summary binds the reference block commitment, so a summary produced at one block cannot be reproduced by re-executing at a later one. Proposals therefore carry a chain anchor (chain_anchor in the proposal metadata): a serialized Miden ChainAnchor capturing the reference block the proposer executed at. Cosigners verify and the executor executes against that anchor, so everyone reproduces the exact summary the signatures authorize regardless of their own sync height. The anchor is validated on receipt — its internal consistency at deserialization, and its block commitment against the one signed into the summary — before anything executes against it. A proposal without an anchor cannot be verified or executed.

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.

    On a chain whose verification_base_fee is non-zero, the request must declare its fee conversion salt. Call fee_conversion_salt(salt) in Rust or withFeeConversionSalt(salt) in TypeScript. The Miden client derives the native 1:1 conversion info from the execution reference header and commits it to the auth argument. The typed proposal builders do this for you. A custom producer must retain the original salt and use it again when rebuilding the request. Do not pass summaryAuthArg(summary) to withFeeConversionSalt: the summary contains the derived commitment, not the original salt.

  • 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_summary commitment. 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 raw tx_summary they are signing — not trust the label or description.

Offline Workflow​

For air-gapped or offline signing scenarios:

┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│ Proposer │ │ Cosigner │ │ Executor │
│ (Online) │ │ (Air-gapped)│ │ (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();

// 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',
});

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);

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.

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 private P2ID 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. Use exportNoteToBytes / importNoteFromBytes (or the browser file variants exportNoteToFile / 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: 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.

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.warn per affected override and exposes multisig.overridesDilutedBySignerGrowth(newNumSigners); the Rust SDK emits a tracing::warn! in propose_transaction and exposes MultisigAccount::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_guardian is a valid override target in both SDKs. Guardian rotation is a note-less operation, so the upstream contract skips the guardian signature check when update_guardian_public_key is the only non-auth procedure called — the multisig quorum alone authorizes it. That quorum is the override on update_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 on update_guardian as 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 runtime set_procedure_threshold requires 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 threshold value
);

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​

// List all pending proposals
const proposals = await multisig.syncProposals();

for (const proposal of proposals) {
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 air-gapped machine: import and sign
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​

MethodDescription
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
guardianClientAccess to underlying GUARDIAN HTTP client

Multisig​

MethodDescription
accountIdGet account ID (hex string)
thresholdGet current threshold
signerCommitmentsGet list of signer commitments
fetchState()Fetch latest state from GUARDIAN
registerOnGuardian()Register new account with GUARDIAN
syncProposals()Sync proposals from GUARDIAN
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 cached proposals
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 }?)Create threshold change proposal
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/MethodDescription
commitmentPublic key commitment (hex)
publicKeySerialized 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​

MethodDescription
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: number
  • numSigners: number
  • signerCommitments: string[]
  • guardianCommitment: string | null
  • vaultBalances: { faucetId, amount }[]

Reading an account's keys: since the account uses the upstream AuthGuardedMultisig component, the Miden SDK's Account.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. The Account must come from the same copy of @miden-sdk/miden-sdk that 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 Miden network
client.sync().await?;

// Inspect account
println!("Threshold: {}", account.threshold()?);
println!("Nonce: {}", account.nonce());
println!("GUARDIAN commitment: {:?}", account.guardian_commitment()?);

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.

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 {
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 air-gapped machine: load and sign
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(&note_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​

MethodDescription
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​

MethodDescription
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​

VariantDescription
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​

VariantDescription
PendingCollecting sigs (proposal.signature_counts(), proposal.metadata.signers)
ReadyThreshold met
FinalizedExecuted

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 (Air-gapped) 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), 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-standards pin in the workspace Cargo.toml
  • TypeScript: the @miden-sdk/miden-sdk pin, whose bundled WASM embeds the matching upstream miden-standards guarded-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 and never had fee conversion info attached. Taking the component from upstream removes the choice, so the pinned roots describe upstream's build and nothing else.

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_threshold writes key the account's procedure_thresholds storage 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 twice 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​

  1. 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
  1. 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
  1. Version numbers are updated in all files (see below).

  2. 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:

FileFieldInherits
Cargo.toml (workspace root)[workspace.package] versionshared, client, contracts, miden-multisig-client
crates/contracts/Cargo.tomlguardian-shared dep version-
crates/client/Cargo.tomlguardian-shared dep version-
crates/miden-multisig-client/Cargo.tomlguardian-client, guardian-shared, miden-confidential-contracts dep versions-
packages/guardian-client/package.jsonversion-
packages/guardian-evm-client/package.jsonversion-
packages/guardian-operator-client/package.jsonversion-
packages/miden-multisig-client/package.jsonversion + @openzeppelin/guardian-client dep version-
packages/package-lock.jsonworkspace lockfilerefresh 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​

  1. Create a draft GitHub Release for review:
gh release create v<version> --generate-notes --draft
  1. Publish the draft when the coordinated Rust, TypeScript, and server-image release workflows should start:
gh release edit v<version> --draft=false
  1. Approve the release environment jobs that should publish. The Rust workflow summary records the source SHA, coordinated version, authentication mode, and ordered per-crate outcomes without credentials.

Additional Resources​