npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@openzeppelin/miden-multisig-client

v0.18.3

Published

TypeScript SDK for Miden multisig accounts with GUARDIAN integration

Readme

@openzeppelin/miden-multisig-client

TypeScript SDK for private multisignature workflows on Miden. This package wraps the on-chain multisig contracts plus Guardian coordination so you can:

  • Create multisig accounts, register them with a GUARDIAN, and keep state off-chain
  • Propose, sign, and execute transactions with threshold enforcement
  • Export/import proposals as files for sharing using side channels

How Private Multisigs & GUARDIAN Work

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

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

Installation

npm install @openzeppelin/[email protected] @miden-sdk/[email protected]

Miden 0.17 requires a new client database: a store created under 0.16 does not open. Pass a fresh storeName when creating the MidenClient, or delete the existing IndexedDB database first. Accounts and pending proposals from the 0.16 line do not carry over. See the compatibility document below.

Why the peer version is exact: the transaction-summary layout and the guarded-multisig procedure roots are only byte-compatible between one @miden-sdk/miden-sdk build and the miden-standards version its WASM embeds, so the SDK pins the exact version the Rust SDK was built against.

Miden compatibility

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

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

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

Setup

import { MultisigClient, FalconSigner } from '@openzeppelin/miden-multisig-client';
import { AuthSecretKey, MidenClient } from '@miden-sdk/miden-sdk';

const midenClient = await MidenClient.createDevnet({ useWorker: false });

// Create a signer from your secret key
const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined);
const signer = new FalconSigner(secretKey);

// Create MultisigClient. Both endpoints are required; construction throws
// when either is omitted. midenRpcEndpoint must point at the same network as
// the injected MidenClient.
const client = new MultisigClient(midenClient, {
  guardianEndpoint: 'http://localhost:3000',
  midenRpcEndpoint: 'https://rpc.devnet.miden.io',
  prover: {
    url: 'https://prover.example',
    retry: { maxAttempts: 4 },
  },
  rpc: {
    retry: { maxAttempts: 3 },
  },
});

The SDK does all of its local work (account reads and writes, note imports and exports, chain syncs, proposal previews) through the MidenClient you pass in, and never opens a second client on that client's store. Pass the client your application executes transactions with: miden-client keeps account state in memory per client, so two live clients writing one store can leave either of them persisting 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 this SDK writes with accounts.insert, so a device's local copy of the account breaks after MultisigClient.load or a syncState() import (0xMiden/web-sdk#441). The symptoms and recovery are in TROUBLESHOOTING.md. Node.js has no worker and is unaffected.

Cedar

@miden-sdk/miden-sdk 0.17.3 has no Cedar preset and no 'cedar' shorthand, so a Cedar client names its endpoints explicitly:

const midenClient = await MidenClient.create({
  rpcUrl: 'https://rpc.cedar.miden.io',
  proverUrl: 'https://tx-prover.cedar.miden.io',
  noteTransportUrl: 'https://transport.cedar.miden.io',
  autoSync: true,
  useWorker: false,
});

const client = new MultisigClient(midenClient, {
  guardianEndpoint: 'https://your-guardian-host',
  midenRpcEndpoint: 'https://rpc.cedar.miden.io',
});

Cedar addresses use the mm prefix, today the same as mainnet's. In Rust, MidenCedar::endpoint() selects the Cedar prover and note transport automatically.

Mainnet

@miden-sdk/miden-sdk 0.17.3 has no createMainnet() preset and no 'mainnet' shorthand for rpcUrl, proverUrl, or noteTransportUrl either, so a mainnet client names its endpoints the same way:

const midenClient = await MidenClient.create({
  rpcUrl: 'https://rpc.mainnet.miden.io',
  proverUrl: 'https://tx-prover.mainnet.miden.io',
  noteTransportUrl: 'https://transport.mainnet.miden.io',
  autoSync: true,
  useWorker: false,
});

const client = new MultisigClient(midenClient, {
  guardianEndpoint: 'https://your-guardian-host',
  midenRpcEndpoint: 'https://rpc.mainnet.miden.io',
  prover: {
    retry: { maxAttempts: 4 },
  },
});

createTestnet and createDevnet set exactly these three endpoints plus autoSync, so the explicit Cedar and mainnet forms are equivalent. Without noteTransportUrl, private notes cannot be relayed between cosigners. Leave prover.url unset so the SDK keeps the Cedar or mainnet prover injected into the MidenClient; a url there overrides it.

For an ECDSA cosigner using an EIP-1193 wallet (including Ledger), call Eip712Signer.connect(provider) once to select the Ethereum address and recover its secp256k1 public key from a dedicated key-discovery signature. Alternatively, pass an already-enrolled public key and its matching address to new Eip712Signer(provider, publicKeyHex, address). LedgerSigner remains an alias. Use the resulting signer with the same client.load(accountId, signer), proposal-creation methods, and multisig.signProposal(id) flow as a raw signer. The Ledger user can create the proposal, then approve it. The approval and Guardian submission each require a separate eth_signTypedData_v4 signature; authenticated reads in the flow also prompt the device. All signatures use the enrolled key; no separate Guardian signing endpoint is needed. The device displays hashes, not human-readable transferred assets. recoverByKey uses a separate GuardianLookup(bytes32 lookupHash) typed-data signature to discover accounts. EIP-712 execution requires an account compiled with the Miden 0.17 multisig authentication component; changing Guardian's signature format does not upgrade an older account's code root.

For example, a Ledger-backed proposer follows the same proposal lifecycle as a raw signer:

import { Eip712Signer } from '@openzeppelin/miden-multisig-client';

const signer = await Eip712Signer.connect(provider); // key-discovery prompt
const multisig = await client.load(accountId, signer); // authenticated reads may prompt
const proposal = await multisig.createAddSignerProposal(newSignerCommitment); // request-auth prompt
await multisig.signProposal(proposal.id); // EIP-712 approval and request-auth prompts
await multisig.executeProposal(proposal.id); // authenticated calls may prompt again

The proposal ID is the transaction-summary commitment. The wallet signs an EIP-712 digest derived from that commitment, so the digest displayed by the wallet need not equal the proposal ID. The approval signature and the Guardian request-authentication signature are separate.

The nested prover configuration is optional. Without it, the injected Miden client's prover is preserved. By default, cloneable remote provers get two total attempts; endpoint-less injected provers, including local and callback provers, run once. A custom URL must be absolute HTTP(S), overrides the injected prover, and never falls back to a default endpoint. Retries apply only to transient proof failures; transaction execution, submission, local state application, and GUARDIAN calls are not retried.

An optional rpc configuration (rpc: { retry: { maxAttempts: 3 } }) tunes retries for the idempotent Miden node reads this SDK issues — on-chain account lookups, state-commitment verification, and the guardian-switch node sync — against transient failures such as rate limiting. The default is two total attempts (one retry); an explicit maxAttempts of 1 opts out. Syncs performed directly on the injected Miden client are owned by the application. Transaction submission is never retried under any configuration: a submission whose outcome is unknown could execute twice if re-sent. There is no timeout setting: the browser WASM RPC client cannot cancel an in-flight request, so a JavaScript-side timeout would abandon a call whose side effects may still land.

Note transport endpoint

Private notes are relayed through a note transport service that is separate from the node RPC. The injected Miden client owns note relay, so the endpoint is set when constructing it rather than on MultisigClientConfig. The createDevnet / createTestnet helpers wire the matching public transport service automatically; a custom node endpoint has no derivable transport service, so private-note relay stays disabled until noteTransportUrl is set explicitly.

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 'devnet' and 'testnet' shorthands, so a custom node on a public network can reuse that network's public transport service.

The full cross-SDK reference, including the Rust equivalents, is docs/MULTISIG_SDK.md.

Usage

Get GUARDIAN Public Key

Before creating a multisig, get the GUARDIAN server's ACK commitment for the account's signature scheme:

const { commitment: guardianCommitment } = await client.guardianClient.getPubkey(signer.scheme);

getPubkey returns { commitment, pubkey } for one signature scheme and defaults to Falcon when no scheme is passed, including on a Guardian that only admits ECDSA accounts (GUARDIAN_ALLOWED_ACCOUNT_SCHEMES=ecdsa): both ACK keys stay loaded for existing accounts. Always pass the scheme of the account you are creating, usually signer.scheme. A Falcon commitment in an ECDSA account is refused at registration with an authorization error, not a scheme mismatch.

Create a Multisig Account

const config = {
  threshold: 2, // Require 2 signatures
  signerCommitments: [
    signer.commitment,      // Your commitment
    otherSigner.commitment, // Cosigner's commitment
  ],
  guardianCommitment,
};

const multisig = await client.create(config, signer);
console.log('Account ID:', multisig.accountId);

Register on GUARDIAN

After creating the account, register it on the GUARDIAN server:

await multisig.registerOnGuardian();

Sign Requests With a Session

Every Guardian request is otherwise signed by the wallet, which on a hardware wallet means a prompt per request. When the Guardian supports sessions, the wallet signs one grant and a non-extractable WebCrypto P-256 key, the delegated signer, signs reads and proposal requests until the session ends:

import {
  IndexedDbSessionKeyStore,
  describeSessionGrant,
} from '@openzeppelin/miden-multisig-client';

const store = new IndexedDbSessionKeyStore(); // optional: survive reloads
// 'expired', 'revoked' or 'rejected': requests are wallet-signed again, so
// offer a new session. 'logout': the app ended it itself.
const onEnded = (reason) => console.info('Guardian session ended:', reason);
const session =
  (await client.resumeSession(signer, store, { onEnded })) ??
  (await client.startSession(signer, {
    store,
    onEnded,
    // Required for raw wallets, which only show a hash.
    confirm: (grant) => showToUser(describeSessionGrant(grant)),
  }));

// ...sync, list proposals and create proposals without wallet prompts.
// Approving a proposal still asks the wallet to sign the transaction.

await client.endSession(store); // this session only
await client.revokeAllSessions(signer, store); // every session of the signer, wallet-signed

The grant covers every account the signer cosigns on this Guardian, now or later, until it expires. It names the website that asked for it (location.origin by default). The page writes that field, so it is unverified: the trustworthy signal is the wallet's own indicator of the requesting site, and Guardian does not check it against requests. Ledger and other EIP-712 wallets sign the grant as readable typed data (GuardianSession: signer, session key, website, issued/expires, scope, Guardian key, network). Without registered display metadata, Ledger devices need "Verbose EIP-712" enabled. Raw (Falcon, raw ECDSA) wallets show only a hash, so startSession requires confirm for them; that shows the fields on an honest page but is no protection against a phishing page, which shows what it wants. Transaction approvals, account registration, delta pushes, candidate abandons and account lookup still use the wallet; the server rejects session credentials there with wallet_signature_required.

The session ends when it expires, when it is logged out or revoked, or when Guardian no longer accepts it (for example after a Guardian restart without persistent sessions, or a Guardian key rotation). Requests then go back to the wallet, the stored key is forgotten, and onEnded is called once with the reason. Removing the signer from an account ends its delegated access to that account (authorization_failed, the session keeps working for the others); adding it back restores any unexpired grant.

Page logout is not revocation: a script that stole the key keeps it, so use revokeAllSessions when in doubt, and run it again 10 minutes later. Revoke-all ends the sessions already registered with Guardian, and a page that got a grant signed can register it for up to about 10 minutes afterwards.

Load an Existing Multisig

The configuration is automatically detected from the account's on-chain storage:

const multisig = await client.load(accountId, signer);

Read Signer Public-Key Commitments

Since accounts use the upstream AuthGuardedMultisig component, the Miden SDK's Account.getPublicKeyCommitments() returns the approver commitments natively. The AccountInspector accessors 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.

Holding only a fetched Miden SDK Account (e.g. a wallet or dApp):

import { AccountInspector } from '@openzeppelin/miden-multisig-client';

const commitments = AccountInspector.getSignerPublicKeyCommitments(account);
const guardianKey = AccountInspector.getGuardianPublicKeyCommitment(account);

Or from a loaded Multisig instance (reads current store-backed state):

const signerKeys = await multisig.getSignerPublicKeyCommitments();

Commitments are ordered by signer index as currently stored; indices re-pack when signers are removed, so index 0 is the key listed first at creation (by convention the creating client's own key) only until the first membership change. Hot/cold roles are a consumer-side convention, not part of on-chain state. getSignerPublicKeyCommitments throws rather than silently returning a truncated list when any signer entry is absent; getGuardianPublicKeyCommitment throws when the guardian entry is missing (the guarded-multisig always includes a guardian). Both are gated on this SDK's pinned contract version and reject accounts built from a different miden-standards release.

The Account passed to AccountInspector must come from the same copy of @miden-sdk/miden-sdk that this package links. An application bundling its own SDK copy (common in wallets) will get a descriptive error from the SDK's instance checks; construct the account with this package's SDK instance instead.

Fetch Account State

const state = await multisig.fetchState();
console.log('Commitment:', state.commitment);
console.log('Created:', state.createdAt);

Sync Account State

syncState() reconciles the local store with GUARDIAN. It first asks GUARDIAN for the nonce and commitment of its canonical state (getCanonicalNonce) and skips the full state fetch when that nonce is not above the local account's (at a matching commitment when equal). The result says which side stood:

const synced = await multisig.syncState();
if (synced.source === 'guardian') {
  console.log('Imported GUARDIAN state at', synced.state.commitment);
} else {
  console.log(`Local nonce ${synced.localNonce} is current (GUARDIAN at ${synced.guardianNonce})`);
}

Either way multisig.account and the cached config reflect the authoritative account afterwards. Call fetchState() when you need GUARDIAN's state copy regardless. The pre-check needs a GUARDIAN server that serves GET /state/nonce (issue #191); against an older server syncState() fails rather than falling back to the full fetch, so deploy the server first.

Creating Proposals

Every create*Proposal method takes its required arguments followed by a single optional options object (issue #387) — there are no positional optional parameters:

createP2idProposal(recipientId, faucetId, amount, { nonce, noteType, reclaimHeight, timelockHeight }?)
createConsumeNotesProposal(noteIds, { nonce }?)
createAddSignerProposal(commitment, { nonce, newThreshold }?)
createRemoveSignerProposal(commitment, { nonce, newThreshold }?)
createChangeThresholdProposal(threshold, { nonce }?)
createUpdateProcedureThresholdProposal(procedure, threshold, { nonce }?)
createSwitchGuardianProposal(endpoint, pubkey, { nonce }?)
createCustomProposal(requestBytes, label, { nonce }?)

All methods accept nonce (identifies the proposal; defaults to the store account's nonce plus one, the nonce the executed transaction will have, as the Rust SDK labels proposals; through 0.18.0 the default was Date.now()). newThreshold defaults to the current threshold on add and to the min of the current threshold and the remaining signer count on remove. The option shapes are exported as CreateProposalOptions, CreateSignerProposalOptions, and CreateP2idProposalOptions.

All methods also accept approvalExpirationDelta (1 to 65535): the number of blocks after the block the proposal binds by which the transaction must be included. Past that block the approvers' signatures no longer authorize it, the SDK refuses to execute it, and the node rejects it as expired. The summary binds the value, so the executing party cannot change it. Omitted, the approval never expires, which is the Miden default.

Breaking change (issue #387): these methods previously took nonce (and newThreshold) as positional parameters. Passing the old positional form now throws at runtime instead of silently applying defaults.

Create a Proposal (Add Signer)

// Create a proposal to add a new signer
const proposal = await multisig.createAddSignerProposal(
  newSignerCommitment, // Commitment of signer to add
  { newThreshold: 3 }, // Options: nonce, newThreshold
);
console.log('Proposal ID:', proposal.id);

Per-procedure threshold overrides are absolute signature counts and are never re-scaled on-chain, so growing the signer set silently lowers every override's effective signing ratio (a 2-of-2 override becomes 2-of-n). createAddSignerProposal logs a console.warn per affected override, and multisig.overridesDilutedBySignerGrowth(newNumSigners) returns them for UIs that want to prompt before proposing. Raise the affected overrides via an update-procedure-threshold proposal alongside the growth.

Sign a Proposal

const signedProposal = await multisig.signProposal(proposal.id);
console.log('Signatures:', signedProposal.signatures.length);

Sync Proposals

Fetches proposals from the GUARDIAN server and reconciles local state. A proposal GUARDIAN reported on an earlier sync but no longer reports (executed, canonicalized, or abandoned) is pruned from the cache. A proposal GUARDIAN holds but has not listed yet (a freshly pushed create) survives the first listing that omits it, absorbing a read-your-writes lag, and is pruned once a second consecutive listing omits it. Proposals GUARDIAN never received (offline switch-guardian creations and imports) are not pruned by listings. The response is parsed in full before the cache changes (a malformed payload rejects and leaves the cache untouched), a proposal that fails metadata-binding verification is still cached and returned with verification.status === 'failed' (see Proposal Verification Status), a proposal executed or signed offline while a sync is in flight keeps that local outcome (it is neither reverted to the listed state nor pruned by that sync), and overlapping calls share a single in-flight sync:

const proposals = await multisig.syncProposals();
for (const p of proposals) {
  console.log(`${p.id}: ${p.status}`);
}

Recover From a Dead Transaction (Abandon)

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

const accepted = await multisig.abandonCandidate(nonce);
console.log(accepted.state); // 'pending'

// The guardian's worker confirms over a short quarantine (typically well
// under a minute) that the transaction did not land, then releases the
// account.
const status = await multisig.abandonStatus(nonce);
// 'waiting' | 'landed' | 'abandoned' | 'unexpected'

Delta History

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

let cursor: string | undefined;
do {
  const page = await multisig.deltaHistory({ limit: 50, cursor });
  for (const entry of page.entries) {
    console.log(entry.nonce, entry.timestamp, entry.outputNotes);
  }
  cursor = page.nextCursor;
} while (cursor !== undefined);

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

Check Proposal Status

Returns cached proposals without making a network request:

const proposals = multisig.listProposals();
for (const p of proposals) {
  if (p.status === 'pending') {
    console.log(`Pending signatures: ${p.signatures.length}`);
  } else if (p.status === 'ready') {
    console.log('Ready to execute!');
  }
}

Proposal Verification Status

syncProposals() checks every proposal's metadata against its signed summary and records the outcome in proposal.verification: { status: 'unchecked' }, { status: 'verified' }, or { status: 'failed', retryable, message }. A proposal that fails the check is still returned, so one stale or corrupt proposal cannot hide the others; only the check itself writes verified, and a freshly parsed or imported proposal is unchecked. retryable: true means the re-execution hit a transient node error and the proposal may verify on the next sync; retryable: false means it cannot be reproduced (tampered metadata, for example) and has to be re-proposed. Verification is deliberately not part of status: a fully signed proposal can be dead, so 'ready' keeps meaning "threshold met" and isProposalActionable(proposal) answers "verified and ready". signProposal and executeProposal re-verify the one proposal they act on and throw the real error. A payload that does not parse at all still rejects, so malformed GUARDIAN data is never silently dropped.

import { isProposalActionable } from '@openzeppelin/miden-multisig-client';

for (const p of await multisig.syncProposals()) {
  if (p.verification.status === 'failed') {
    const { retryable, message } = p.verification;
    console.log(`${p.id}: ${retryable ? 'retry later' : 're-propose'} — ${message}`);
  } else if (isProposalActionable(p)) {
    await multisig.executeProposal(p.id);
  }
}

Verification and execution run at the chain tip. Since Miden 0.17 a multisig summary binds the block its auth args name (the bound block), and every request the SDK builds declares that block (withBlockNumbers), so the summary reproduces at any later tip. An execution runs at the Miden client's sync height, so syncProposals, signProposal, executeProposal, createTransactionProposalRequest and the custom and offline paths sync the client first. Foreign accounts, the fee faucet among them, load at the tip, so a proposal stays verifiable however long it waits for signatures, even after the node has pruned the bound block's account state (devnet keeps about 50 blocks). The proposal's boundBlockNum records the bound block. The SDK reads that block from the signed summary when it creates a proposal and when it verifies one, and refuses a proposal whose boundBlockNum disagrees (BoundBlockMismatchError), as the Rust SDK does.

createTransactionProposalRequest(proposalId) returns the final, fully signed request for an integration that proves and submits with its own pipeline. Execute it at the chain tip, without an anchor, on a client that has synced recently: this call syncs first, and an execution loads the fee faucet at the client's sync height, which a node serves for only about 50 blocks.

Execute a Proposal

When a proposal has enough signatures:

if (proposal.status === 'ready') {
  await multisig.executeProposal(proposal.id);
  console.log('Transaction executed on-chain!');
}

GUARDIAN pins every proposal to the account state its transaction must execute on. Execution (and createTransactionProposalRequest and prepareCustomExecution) refuses a proposal pinned to a state other than the one this client holds: sync with syncState() and retry, or create a new proposal if the account has moved past that state. A switch_guardian proposal is checked the same way when the pre-switch GUARDIAN serves it. When that GUARDIAN never received it (made offline), is unreachable, or answers with an error, the switch executes without the check (logging a warning in the last two cases), so a GUARDIAN the account is rotating away from cannot block the rotation. After the switch the delta is pushed back to it best-effort, naming the state the switch executed on, as the Rust SDK does.

Export Proposal for Offline Signing

const exported = await multisig.exportProposal(proposal.id);
// Send `exported` to offline signer
console.log('TX Summary:', exported.txSummaryBase64);
console.log('Commitment to sign:', exported.commitment);

Import and Sign a Proposal Offline

Imported proposals are now validated against their transaction summary before they are cached or signed:

const imported = await multisig.importProposal(jsonFromCosigner);
const signedJson = await multisig.signProposalOffline(imported.id);
console.log(signedJson);

Leave an Unreachable Guardian (Offline Switch)

A switch-Guardian proposal can be created fully offline — nothing is pushed to the current Guardian, so an account can leave an operator that is down (mirrors the Rust create_proposal_offline). Only switch-Guardian proposals support this: every other type needs a Guardian acknowledgment at execution. The new endpoint must be reachable; its /pubkey commitment is verified before anything is signed.

// Proposer: build, sign, and cache locally — the current Guardian is never contacted.
const exported = await multisig.createSwitchGuardianProposalOffline(
  'https://new-guardian.example',
  newGuardianPubkey,
);
const json = JSON.stringify(exported); // share with cosigners side-channel

// Cosigner: import, sign, send the updated JSON back.
const imported = await cosigner.importProposal(json);
const signedJson = await cosigner.signProposalOffline(imported.id);

// Proposer: import the cosigned proposal and execute. The push to the old
// Guardian is best-effort; registration happens on the new Guardian.
const ready = await multisig.importProposal(signedJson);
await multisig.executeProposal(ready.id);

For hand-rolled export/import flows, computeCommitmentFromTxSummary is exported: a proposal's commitment is its transaction summary's commitment, recomputed from the serialized summary.

Custom Proposal Types

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

import { buildP2idTransactionRequest } from '@openzeppelin/miden-multisig-client';

// Producer: build a transaction and propose it under a custom label.
// The options object accepts `noteType` (`NoteType.Public` (default) or
// `NoteType.Private`, from `@miden-sdk/miden-sdk`); a private note publishes
// only its hash on chain, so the recipient needs the note shared out-of-band.
// It also accepts `reclaimHeight`/`timelockHeight` (absolute block heights,
// issue #366); presence of either builds a P2IDE note instead of plain P2ID.
// The typed path is `createP2idProposal(recipient, faucet, amount,
// { nonce, noteType, reclaimHeight, timelockHeight })`, which persists the
// choices in signed metadata.
//
// The builder takes the Miden client because the executing account decides
// the auth args the request has to carry (see below).
const { request, salt } = await buildP2idTransactionRequest(
  midenClient, senderId, recipientId, faucetId, amount,
);
const proposal = await multisig.createCustomProposal(request.serialize(), 'b2agg');

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

// Producer (once threshold is met): bind-check the request and fetch the
// validated advice (cosigner signatures + GUARDIAN ack). `prepareCustomExecution`
// verifies the request against the signed commitment *before* the ack request.
const advice = await multisig.prepareCustomExecution(proposal.id, request.serialize());

// The browser TransactionRequest is immutable, so rebuild from the same recipe
// (inputs + salt + bound block) with the advice, then submit. `submitTransaction`
// executes at the chain tip: the rebuilt request declares the block it binds.
// `prepareCustomExecution` has checked `boundBlockNum` against the signed summary.
const boundBlockNum = proposal.metadata.boundBlockNum;
const { request: finalRequest } = await buildP2idTransactionRequest(
  midenClient, senderId, recipientId, faucetId, amount,
  { salt, boundBlockNum, signatureAdviceMap: advice },
);
await multisig.submitTransaction(proposal.id, finalRequest);

Since Miden 0.17 a multisig auth procedure reads three words out of the transaction's auth arg: the block the summary binds together with the approval expiration, the salt, and the fee conversion info. The exported builders get them from client.feeAwareTransactionRequestBuilder(account, { feeConversionSalt, boundBlockNum }), which sets the commitment as the request's auth arg, puts the preimage in its advice map, and declares the bound block with withBlockNumbers so the request executes at the chain tip. A request whose auth args bind a block it does not declare is refused with BoundBlockNotDeclaredError. An integration that assembles a request itself must start from that builder for a multisig account, and must not call withFeeConversionSalt or withAuthArg on it: the two setters clear each other and either one discards the auth args. The approval never expires unless the integration asks for one through approvalExpirationDelta, which the create*Proposal methods forward from their options.

The integration keeps its own recipe (build inputs + salt) and reads the bound block from proposal.metadata.boundBlockNum, so it can reproduce the exact transaction at execute time - the SDK does not store the serialized request. Verification and prepareCustomExecution refuse a custom proposal whose boundBlockNum disagrees with its signed summary, as for every other type. A proposal a 0.18.0 client made has none; summaryBoundBlockNum reads its block from the summary. The binding check guarantees the rebuilt transaction matches the commitment the cosigners signed. The request may bind any block up to the client's sync height: createCustomProposal syncs up to that block when the client is behind it and derives the summary at the chain tip.

The summary binds the salt itself, so the value the cosigners signed over is readable back out of it, as is the block it binds:

import {
  summaryApprovalExpirationBlockNum,
  summaryBoundBlockNum,
  summarySalt,
} from '@openzeppelin/miden-multisig-client';
import { TransactionSummary } from '@miden-sdk/miden-sdk';

const summary = TransactionSummary.deserialize(bytes);
const salt = summarySalt(summary);
const expiresAt = summaryApprovalExpirationBlockNum(summary); // undefined: never
const boundBlockNum = summaryBoundBlockNum(summary);

The SDK's own verification path compares summarySalt(summary) with the proposal's saltHex metadata before rebuilding, so a proposal GUARDIAN serves with a summary and metadata that disagree fails by name rather than as a generic summary mismatch. A request carries the same two values in its auth args; requestSaltHex(request) and requestBoundBlockNum(request) read them.

Rust ↔ TS parity: both SDKs expose the same producer surface — createCustomProposal / propose_custom_transaction, prepareCustomExecution / prepare_custom_execution, and submitTransaction / submit_transaction. The only difference is advice injection: Rust mutates the request's advice map in place (request.advice_map_mut().extend(advice)), while the immutable browser TransactionRequest is rebuilt from the recipe with the advice.

Recover An Account By Key

When the wallet only holds a signing key from the account's authorization set, it does not yet know the account ID. recoverByKey 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) {
  // No account on this Guardian operator authorizes the key.
} else {
  for (const { accountId, state } of recovered) {
    const multisig = await client.load(accountId, signer);
    // ...
  }
}

The Signer passed to recoverByKey MUST implement signLookupMessage (the bundled FalconSigner, EcdsaSigner, and Eip712Signer do). The lookup endpoint authenticates by proof-of-possession of the queried commitment — same key that already authenticates per-account requests, so revealing the account ID does not grant any new capability. See the design doc for the security analysis.

Multiple matches are returned uniformly: a key may legitimately authorize several accounts, and the helper surfaces all of them rather than silently picking one. The returned list is empty (not an error) when no account authorizes the queried commitment.

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.

const multisig = await client.load(accountId, signer);

const report = await multisig.recoverNotes();
console.log(`recovered ${report.imported} notes`);
for (const problem of report.problems) {
  console.log(`step ${problem.step} did not run: ${problem.reason}`);
}

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; a strategy that cannot run at all (GUARDIAN unreachable, chain tip unresolvable, broken local store) becomes a RecoveryStepProblem entry instead of aborting the flow, and retryable: true means rerunning the flow — which is idempotent — can plausibly recover more. In brief:

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

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

Preserving notes across a guardian switch

Pending proposals do not survive a guardian switch — the new GUARDIAN is registered with bare account state — so the notes embedded in the old GUARDIAN's pending consume-notes proposals are the one recovery source recoverNotes can no longer reach after the repoint. executeProposal runs the proposal-import slice automatically on the switch-guardian path (against the old GUARDIAN, before the switch transaction executes), best-effort and bounded by a 30s timeout with cooperative cancellation; the transport drain and public backfill deliberately do not run there (an intact local store loses nothing they rescan). When repointing a client by hand via setGuardianClient, request the same preservation first:

const report = await multisig.preservePreSwitchProposalNotes();

Transaction Utilities

The package also exports utility functions for building transactions:

import {
  normalizeHexWord,
  hexToUint8Array,
  signatureHexToBytes,
  buildSignatureAdviceEntry,
} from '@openzeppelin/miden-multisig-client';

// Normalize hex for Word.fromHex (pads to 64 chars)
const normalized = normalizeHexWord('abc123');
// => '0x0000...abc123'

// Convert hex to bytes
const bytes = hexToUint8Array('deadbeef');
// => Uint8Array([0xde, 0xad, 0xbe, 0xef])

// Add auth scheme prefix to signature
const sigBytes = signatureHexToBytes(signatureHex);
// => Uint8Array with the Falcon Poseidon2 auth prefix

Consume-notes metadata versions

consume_notes proposals come in two metadata shapes. The metadataVersion field on ConsumeNotesProposalMetadata is the discriminator.

  • v1 (legacy) — metadataVersion absent. The proposal carries only noteIds; verification rebuilds the transaction request by fetching each note from the cosigner's own local Miden store (IndexedDB in the browser). If the verifier does not have the note locally (cursor advanced past the block, store was cleared via clearMidenDatabase(), private-note transport pruned the blob), verification throws LegacyConsumeNotesNoteMissingError and the cosigner cannot sign. This is the failure tracked by issue #229.

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

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

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

import {
  MAX_CONSUME_NOTES_METADATA_BYTES,
  CONSUME_NOTES_METADATA_VERSION_V2,
  ConsumeNoteNotAuthenticatedError,
  ConsumeNotesMetadataOversizeError,
  LegacyConsumeNotesNoteMissingError,
  NoteBindingMismatchError,
  UnsupportedMetadataVersionError,
  isConsumeNotesV2,
} from '@openzeppelin/miden-multisig-client';

Error taxonomy

Each error class exposes a stable .code string identical to the Rust SDK's MultisigError::code() value, so cross-SDK tests and operator dashboards can branch on one taxonomy.

| Error class | .code | When | |---|---|---| | NoteBindingMismatchError | consume_notes_note_binding_mismatch | v2: notes.length !== noteIds.length, or note.id().toString() !== noteIds[i] | | UnsupportedMetadataVersionError | consume_notes_unsupported_metadata_version | Unrecognized version (including v1 on a cut-over build) | | ConsumeNotesMetadataOversizeError | consume_notes_metadata_oversize | v2 metadata serialization exceeds 256 KiB at creation | | LegacyConsumeNotesNoteMissingError | consume_notes_legacy_note_missing | v1 path: local store does not contain the referenced note | | ConsumeNoteNotAuthenticatedError | consume_notes_note_not_authenticated | A note could not be authenticated: not committed on chain yet, the node served no proof, the import failed, or the store could not verify it after a sync |

Cut-over policy

The LEGACY_CONSUME_NOTES_ENABLED build-time constant (default true in this transitional release) gates whether this build accepts v1 metadata for verification. A future cut-over release will flip the default to false, at which point v1 proposals are refused with UnsupportedMetadataVersionError(undefined) on every code path. Deployments should drain or re-propose any v1 consume_notes proposals in flight before upgrading past the cut-over client version. Tracked by the 006-consume-notes-metadata feature spec in the repository.

Testing

npm test           # Run tests once
npm run test:watch # Run tests in watch mode