Skip to main content
Version: 0.15 (unstable)

Miden Multisig SDK

An SDK for creating and managing multisignature accounts on the Miden network. Available for both TypeScript (web/browser) and Rust (native/server) environments.

New to Guardian? Read docs/CONCEPTS.md for the trust model and state/delta lifecycle, and docs/architecture/services.md for the server-side surface this SDK targets.

Table of Contents


Quick Start

Installation

The multisig sdk has as peer dependency on the miden-sdk, you will need to install both.

TypeScript (npm)

npm install @openzeppelin/miden-multisig-client @miden-sdk/miden-sdk

Rust (Cargo.toml)

[dependencies]
miden-multisig-client = "0.15.0"
miden-client = "0.15.0"

5-Minute Example

Create a 1-of-3 multisig account, propose a transfer, collect signatures, and execute.

TypeScript

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

// 1. Setup clients
const midenClient = await MidenClient.createDevnet();
const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined);
const signer = new FalconSigner(secretKey);
const client = new MultisigClient(midenClient, {
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://rpc.devnet.miden.io',
});

// 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,
guardianEnabled: true,
};
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!');

Rust

use miden_multisig_client::{MultisigClient, TransactionType, Endpoint};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Setup client
let mut client = MultisigClient::builder()
// the Miden node RPC endpoint
.miden_endpoint(Endpoint::new("http://localhost:57291"))
// the GUARDIAN server endpoint
.guardian_endpoint("http://localhost:50051")
// the directory where the miden-client will store the account data
.account_dir("/tmp/multisig-client")
// generate a new Falcon keypair for GUARDIAN authentication
.generate_key()
.build()
.await?;

// 2. Create 1-of-3 multisig account
let signer_commitments = vec![
client.user_commitment(),
cosigner1_commitment,
cosigner2_commitment,
];
let account = client.create_account(1, signer_commitments).await?;
client.push_account().await?;

println!("Account created: {}", account.id());

// 3. Create a transfer proposal
let tx = TransactionType::transfer(recipient_id, faucet_id, 1000);
let proposal = client.propose_transaction(tx).await?;

println!("Proposal created: {}", proposal.id);

// 4. Cosigners sign (only one cosigner is needed)
client.sign_proposal(&proposal.id).await?;

// 5. Execute when threshold is met
client.execute_proposal(&proposal.id).await?;

println!("Transfer executed!");
Ok(())
}

Core Concepts

Multisig Accounts

A multisig account requires M-of-N signatures to authorize transactions:

  • Threshold (M): Minimum signatures required
  • Signers (N): Total number of authorized cosigners
  • Commitment: Each signer's Falcon public key commitment (32 bytes, 64 hex chars)

Guardian

GUARDIAN is a coordination server that:

  • Stores the account state off-chain
  • Coordinates proposal signing between cosigners
  • Provides acknowledgment signatures for on-chain execution (ensures the new state is available for the rest of the cosigners)
  • Keeps multisig metadata private

Note: GUARDIAN server setup is covered in separate documentation. This SDK assumes a running GUARDIAN instance.

Proposal Lifecycle

┌──────────┐     ┌──────────┐     ┌───────────┐
│ PENDING │ ──► │ READY │ ──► │ FINALIZED │
└──────────┘ └──────────┘ └───────────┘
│ │ │
Collecting Threshold Executed
signatures met on-chain

States:

  • Pending: Proposal created, collecting signatures (shows X/Y signed)
  • Ready: Threshold met, can be executed
  • Finalized: Executed on-chain or discarded

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:

  • Createpropose_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.

  • Executeprepare_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(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(req).await?;

The SDK owns the security-critical pieces (binding check, signature + ack assembly, ack-after-binding ordering); the integration owns only the transaction recipe + submit. execute_proposal on a custom type returns a clear error pointing to prepare_custom_execution. Because the integration must rebuild its transaction to execute, custom execution is performed by a party that holds the recipe (typically the producer), not by an arbitrary cosigner.

The returned advice is keyed by the signer and GUARDIAN commitments (domain-separated digests over the signed tx_summary), the same keys the SDK's own built-in execution uses. Extending a transaction's advice map with it therefore does not collide with the transaction's ordinary inputs; the integration extends rather than replaces its advice map.

Security: for first-party types the SDK reconstructs the transaction from metadata and checks it against the signed tx_summary commitment. For custom types there is no such reconstruction, so the SDK cannot verify that display metadata (e.g. description) matches what the transaction actually does. Cosigners must verify the raw tx_summary they are signing — not trust the label or description.

Offline Workflow

For air-gapped or offline signing scenarios:

┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│ Proposer │ │ Cosigner │ │ Executor │
│ (Online) │ │ (Air-gapped)│ │ (Online) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ Export proposal.json │ │
│──────────────────────►│ │
│ │ │
│ │ Sign offline │
│ │ │
│ │ Export signed.json │
│ │──────────────────────►│
│ │ │
│ │ Import & Execute
▼ ▼ ▼

TypeScript SDK Guide

Installation & Setup

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

// Initialize Miden client (connects to Miden node)
const midenClient = await MidenClient.createDevnet();

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

// Initialize multisig client
const client = new MultisigClient(midenClient, {
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://rpc.devnet.miden.io',
});

Creating Accounts

// Get GUARDIAN server's public key commitment
const guardianCommitment = await client.guardianClient.getPubkey();

// Define multisig configuration
const config: MultisigConfig = {
threshold: 2, // 2 signatures required
signerCommitments: [ // 3 authorized signers
signer.commitment, // Your commitment
'0x1234...abcd', // Cosigner 1
'0x5678...efgh', // Cosigner 2
],
guardianCommitment, // GUARDIAN server commitment
guardianEnabled: true,
};

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

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.

Proposal Operations

P2ID Transfer (Send Funds)

const proposal = await multisig.createP2idProposal(
recipientAccountId, // Recipient's account ID
faucetAccountId, // Faucet (token) ID
1000n // Amount to send
);

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

Add Signer

const proposal = await multisig.createAddSignerProposal(
newSignerCommitment, // New signer's public key commitment
undefined, // Optional nonce
newThreshold // Optional new threshold
);

Remove Signer

const proposal = await multisig.createRemoveSignerProposal(
signerToRemove, // Signer's commitment to remove
undefined, // Optional nonce
newThreshold // Optional new threshold
);

Change Threshold

const proposal = await multisig.createChangeThresholdProposal(
newThreshold // New threshold value
);

Switch GUARDIAN Provider

const proposal = await multisig.createSwitchGuardianProposal(
newGuardianEndpoint, // New GUARDIAN server URL
newGuardianCommitment // New GUARDIAN server's public key
);

Signing & Executing Proposals

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

for (const proposal of proposals) {
console.log(`${proposal.id}: ${proposal.status.type}`);

if (proposal.status.type === 'pending') {
console.log(` Signatures: ${proposal.status.signaturesCollected}/${proposal.status.signaturesRequired}`);
}
}

// Sign a proposal
const signed = await multisig.signProposal(proposalId);

// Execute when ready
if (signed.status.type === 'ready') {
await multisig.executeProposal(proposalId);
}

Offline Export/Import

// Export proposal for offline signing
const json = multisig.exportProposalToJson(proposalId);
// Share via file, QR code, etc.

// On air-gapped machine: import and sign
const imported = multisig.importProposal(json);
const signedJson = multisig.signProposalOffline(proposalId);

// Back on online machine: import signed proposal
const signedProposal = multisig.importProposal(signedJson);
await multisig.executeProposal(signedProposal.id);

API Reference

MultisigClient

MethodDescription
create(config, signer)Create new multisig account
load(accountId, signer)Load existing account from GUARDIAN
recoverByKey(signer)Discover accounts that authorize the signer's key and fetch each current state
guardianClientAccess to underlying GUARDIAN HTTP client

Multisig

MethodDescription
accountIdGet account ID (hex string)
thresholdGet current threshold
signerCommitmentsGet list of signer commitments
fetchState()Fetch latest state from GUARDIAN
registerOnGuardian()Register new account with GUARDIAN
syncProposals()Sync proposals from GUARDIAN
listProposals()Get cached proposals
createP2idProposal(recipient, faucet, amount, nonce?)Create transfer proposal
createConsumeNotesProposal(noteIds, nonce?)Create note consumption proposal
createAddSignerProposal(commitment, nonce?, threshold?)Create add signer proposal
createRemoveSignerProposal(commitment, nonce?, threshold?)Create remove signer proposal
createChangeThresholdProposal(threshold, nonce?)Create threshold change proposal
createSwitchGuardianProposal(endpoint, pubkey, nonce?)Create GUARDIAN switch proposal
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

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

Returns DetectedMultisigConfig:

  • threshold: number
  • numSigners: number
  • signerCommitments: string[]
  • guardianEnabled: boolean
  • guardianCommitment: string
  • vaultBalances: { faucetId, amount }[]

Rust SDK Guide

Installation & Setup

use miden_multisig_client::{
MultisigClient, MultisigClientBuilder,
MultisigAccount, TransactionType,
Proposal, ProposalStatus,
KeyManager, GuardianKeyStore,
Endpoint, Word, AccountId, SecretKey,
};

// Build client with fluent API
let mut client = MultisigClient::builder()
.miden_endpoint(Endpoint::new("http://localhost:57291"))
.guardian_endpoint("http://localhost:50051")
.account_dir("/tmp/multisig-data")
.generate_key() // Or: .with_secret_key(key)
.build()
.await?;

Creating Accounts

// Collect signer commitments (your key + cosigners)
let signer_commitments = vec![
client.user_commitment(), // Your commitment
commitment_from_hex("0x1234...")?, // Cosigner 1
commitment_from_hex("0x5678...")?, // Cosigner 2
];

// Create 2-of-3 multisig
let account = client.create_account(2, signer_commitments).await?;

// Register with GUARDIAN
client.push_account().await?;

println!("Created account: {}", account.id());
println!("Threshold: {}", account.threshold()?);
println!("Signers: {:?}", account.cosigner_commitments_hex());

Loading Existing Accounts

// Pull account from GUARDIAN (as a cosigner)
let account = client.pull_account(account_id).await?;

// Sync with Miden network
client.sync().await?;

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

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.

Transaction Types

// P2ID Transfer
let tx = TransactionType::transfer(recipient_id, faucet_id, 1000);

// Consume Notes
let tx = TransactionType::consume_notes(vec![note_id1, note_id2]);

// Add Cosigner
let tx = TransactionType::add_cosigner(new_commitment);

// Remove Cosigner
let tx = TransactionType::remove_cosigner(commitment_to_remove);

// Update Signers (change threshold and/or signer set)
let tx = TransactionType::update_signers(new_threshold, new_signer_list);

// Switch GUARDIAN Provider
let tx = TransactionType::switch_guardian(new_endpoint, new_commitment);

Proposal Operations

// Create and submit proposal
let proposal = client.propose_transaction(tx).await?;
println!("Proposal ID: {}", proposal.id);

// Or with offline fallback
match client.propose_with_fallback(tx).await? {
ProposalResult::Online(proposal) => {
println!("Submitted to GUARDIAN: {}", proposal.id);
}
ProposalResult::Offline(exported) => {
// Save for file-based sharing
std::fs::write("proposal.json", exported.to_json()?)?;
}
}

Listing & Signing Proposals

// List pending proposals
let proposals = client.list_proposals().await?;

for proposal in &proposals {
match &proposal.status {
ProposalStatus::Pending => {
let (signatures_collected, signatures_required) = proposal.signature_counts();
println!("{}: {}/{} signatures",
proposal.id, signatures_collected, signatures_required);
println!(" Signed by: {:?}", proposal.metadata.signers);
}
ProposalStatus::Ready => {
println!("{}: Ready to execute", proposal.id);
}
ProposalStatus::Finalized => {
println!("{}: Already executed", proposal.id);
}
}
}

// Sign a proposal
client.sign_proposal(&proposal_id).await?;

// Execute when ready
client.execute_proposal(&proposal_id).await?;

Offline Export/Import

// Create offline proposal (when GUARDIAN unavailable)
let exported = client.create_proposal_offline(tx).await?;
std::fs::write("proposal.json", exported.to_json()?)?;

// On air-gapped machine: load and sign
let json = std::fs::read_to_string("proposal.json")?;
let mut exported: ExportedProposal = serde_json::from_str(&json)?;
client.sign_imported_proposal(&mut exported)?;
std::fs::write("signed.json", exported.to_json()?)?;

// Back online: execute
let json = std::fs::read_to_string("signed.json")?;
let exported: ExportedProposal = serde_json::from_str(&json)?;
client.execute_imported_proposal(&exported).await?;

Note Filtering

use miden_multisig_client::NoteFilter;

// List all consumable notes
let notes = client.list_consumable_notes().await?;

// Filter by faucet
let filter = NoteFilter::by_faucet(faucet_id);
let notes = client.list_consumable_notes_filtered(filter).await?;

// Filter by faucet with minimum amount
let filter = NoteFilter::by_faucet_min_amount(faucet_id, 5000);
let notes = client.list_consumable_notes_filtered(filter).await?;

for note in notes {
println!("Note {}: {} tokens", note.id, note.amount_for_faucet(faucet_id));
}

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

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_enabled()GUARDIAN integration enabled
guardian_commitment()GUARDIAN server commitment

TransactionType

VariantDescription
P2ID { recipient, faucet_id, amount }Transfer funds
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,
guardianEnabled: true,
};

const treasury = await client.create(config, ceoSigner);
await treasury.registerOnGuardian();

// CEO proposes payment to vendor
const payment = await treasury.createP2idProposal(
vendorAccountId,
usdcFaucetId,
50000n
);

// CFO reviews and signs
const cfoMultisig = await cfoClient.load(treasury.accountId, cfoSigner);
await cfoMultisig.syncProposals();
await cfoMultisig.signProposal(payment.id);

// Payment executes (threshold met: CEO + CFO = 2)
await treasury.executeProposal(payment.id);

Use Case 2: Secure Operations (3-of-5)

High-security operations requiring 3 of 5 board members.

// Create 3-of-5 multisig
let board_commitments = vec![member1, member2, member3, member4, member5];
let account = client.create_account(3, board_commitments).await?;

// Propose removing a compromised member
let tx = TransactionType::remove_cosigner(compromised_member);
let proposal = client.propose_transaction(tx).await?;

// Three members must sign
// member1.sign_proposal(...)
// member2.sign_proposal(...)
// member3.sign_proposal(...)

// Execute with 3 signatures
client.execute_proposal(&proposal.id).await?;

Use Case 3: Note Consumption

Claiming tokens sent to the multisig.

// Check for incoming notes
const notes = await multisig.getConsumableNotes();

console.log('Pending notes:');
for (const note of notes) {
for (const asset of note.assets) {
if (asset.isFungible()) {
console.log(` ${note.id}: ${asset.amount()} from faucet ${asset.faucetId()}`);
}
}
}

// Create proposal to consume all notes
const noteIds = notes.map(n => n.id);
const proposal = await multisig.createConsumeNotesProposal(noteIds);

// After threshold signatures...
await multisig.executeProposal(proposal.id);

console.log('Notes consumed, funds now in vault');

Offline Workflow

Complete Flow Diagram

┌─────────────────────────────────────────────────────────────────────┐
│ OFFLINE SIGNING FLOW │
└─────────────────────────────────────────────────────────────────────┘

PROPOSER (Online) COSIGNER (Air-gapped) EXECUTOR (Online)
───────────────── ──────────────────── ────────────────
│ │ │
│ create_proposal_offline() │ │
│ or propose_with_fallback() │ │
▼ │ │
┌──────────┐ │ │
│ Export │ │ │
│ proposal │ │ │
│ .json │─────── USB ─────────►│ │
└──────────┘ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Import JSON │ │
│ │ Sign offline │ │
│ │ Export JSON │ │
│ └──────────────┘ │
│ │ │
│ │─────── USB ───────────────►│
│ │ ▼
│ │ ┌─────────────┐
│ │ │ Import JSON │
│ │ │ Verify sigs │
│ │ │ Execute tx │
│ │ └─────────────┘
▼ ▼ ▼

Export Format (JSON)

{
"version": 1,
"account_id": "0x7925bdcc9c4df01068e79d4c94beeb",
"id": "0xabcd1234...",
"nonce": 5,
"tx_summary": {
"...": "transaction summary JSON"
},
"signatures": [
{
"signer_commitment": "0x1234...",
"signature": "0x5678..."
}
],
"signatures_required": 2,
"metadata": {
"proposal_type": "add_signer",
"salt_hex": "0x...",
"new_threshold": 2,
"signer_commitments_hex": ["0x...", "0x..."]
}
}

Version Compatibility

SDK Versionmiden-clientmiden-sdk (npm)Notes
0.15.x0.15.0^0.15.0Miden 0.15 protocol; v1 account IDs, bech32m addresses
0.14.x0.14.x^0.14.0Devnet default, MidenClient public API
0.13.x0.13.0^0.13.0ECDSA support, wallet signers
0.12.x0.12.5^0.12.5Initial release

Breaking Changes

Check the GitHub release notes for breaking changes between versions.


Releasing

Steps for publishing a new version of the SDK (Rust crates + TypeScript packages).

Pre-Release Checklist

  1. All tests pass:
# Rust
cargo test -p guardian-shared
cargo test -p guardian-server --lib

# TypeScript
cd packages/guardian-client && npm test
cd packages/guardian-evm-client && npm test
cd packages/miden-multisig-client && npm test
  1. TypeScript packages build cleanly:
cd packages/guardian-client && npm run build
cd packages/guardian-evm-client && npm run build
cd packages/miden-multisig-client && npm run build
  1. Version numbers are updated in all files (see below).

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/miden-multisig-client/package.jsonversion + @openzeppelin/guardian-client dep version-

The server, miden-rpc-client, miden-keystore, and example crates have their own independent versions and are not published.

Publishing Rust Crates

Publish in dependency order (leaves first). Each crate must be available on crates.io before its dependents can be published.

# 1. No internal deps
cargo publish -p guardian-shared

# 2. Depends on shared
cargo publish -p guardian-client
cargo publish -p miden-confidential-contracts

# 3. Depends on shared + client + contracts
cargo publish -p miden-multisig-client

Wait for each step to finish before proceeding to the next (crates.io index needs to update).

Publishing TypeScript Packages

Publish in dependency order:

# 1. Build TypeScript packages
cd packages/guardian-client && npm run build
cd packages/guardian-evm-client && npm run build
cd packages/miden-multisig-client && npm run build

# 2. Publish base clients first (no internal deps)
cd packages/guardian-client && npm publish --access public
cd packages/guardian-evm-client && npm publish --access public

# 3. Publish miden-multisig-client (depends on guardian-client)
cd packages/miden-multisig-client && npm publish --access public

Post-Release

  1. Tag the release:
git tag v0.15.0
git push origin v0.15.0
  1. Create a GitHub release from the tag with release notes.

Additional Resources