@openzeppelin/miden-multisig-client
v0.18.3
Published
TypeScript SDK for Miden multisig accounts with GUARDIAN integration
Keywords
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:
- A proposer pushes a delta (transaction plan) to Guardian. GUARDIAN tracks who signed and emits an ack signature once the threshold is met.
- Cosigners fetch pending deltas, verify details locally, sign the transaction summary, and push signatures back to GUARDIAN.
- Once ready, any cosigner builds the final transaction using all cosigner signatures + the GUARDIAN ack, executes it on-chain.
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-sdkbuild and themiden-standardsversion 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 againThe 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-signedThe 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(andnewThreshold) 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, andsubmitTransaction/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 browserTransactionRequestis 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 asinvalidoutcomes instead of blocking the rest. - Public backfill — tag-scoped historical scan (genesis to tip by
default) importing discovered public notes with their proofs, never
touching the global sync height; cost scales with matches, not range
length. 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 inuncoveredinstead of failing the flow.
For the full report semantics see
docs/MULTISIG_SDK.md.
Preserving notes across a guardian switch
Pending proposals do not survive a guardian switch — the new GUARDIAN is
registered with bare account state — so the notes embedded in the old
GUARDIAN's pending consume-notes proposals are the one recovery source
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 prefixConsume-notes metadata versions
consume_notes proposals come in two metadata shapes. The
metadataVersion field on ConsumeNotesProposalMetadata is the
discriminator.
v1 (legacy) —
metadataVersionabsent. The proposal carries onlynoteIds; 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 viaclearMidenDatabase(), private-note transport pruned the blob), verification throwsLegacyConsumeNotesNoteMissingErrorand the cosigner cannot sign. This is the failure tracked by issue #229.v2 (self-contained) —
metadataVersion: 2plus anotesarray of base64-encodedNote.serialize()bytes, aligned by index withnoteIds. 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 forp2id).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 onesyncStateif the store is behind the note's block). Verification therefore reads and writes the local store and contacts the node.createConsumeNotesProposaldoes 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