Skip to main content
Version: Next (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/[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 pruned for an older proposal. Upgrade every party that signs or executes. A listing keeps working either way: a proposal that cannot be verified is returned with verification set to failed rather 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 call withFeeConversionSalt or withAuthArg on it. In Rust, build them with client.multisig_auth_args(salt, bound_block_num, approval_expiration_delta) and attach them with builder.multisig_auth_args(&auth_args) from TransactionRequestBuilderExt; do not declare fee_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 / createCustomProposal anchor 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 with BoundBlockNotDeclaredError / MultisigError::BoundBlockNotDeclared. Serialize the request with a client on the SDK's miden-client pin (see MIDEN_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_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.

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_guardian included, submits a transaction to the Miden network. What switch_guardian alone 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, for consume_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 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: 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.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 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 let signaturesCollected decide, 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​

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
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/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 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(&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 (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-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. 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_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 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​

  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​