Multi-signature account management on the Miden network. Multiple parties collectively control an account, requiring a configurable threshold of signatures to execute transactions.
This branch integrates Ledger directly over USB, using WebHID and the Ledger
Ethereum app to sign EIP-712 messages. It does not use Ledger Wallet Provider:
no Ledger API key, app ID, dAppIdentifier or originToken is needed. Para
credentials and the Miden Wallet extension are also unnecessary for this path.
Prerequisites: Node.js 20.19+, npm, desktop Chrome or Edge, a USB data cable, and a Ledger with its Ethereum app installed. Use localhost or HTTPS; Safari, Firefox and mobile USB are not supported by this implementation. Use a separate browser profile and disposable devnet accounts for testing.
From the repository root:
git switch ledger-integration
cd bin/coordinator-frontend
npm ci
# Only if you do not already have .env.local:
cp .env.example .env.localEdit .env.local before starting the app:
NEXT_PUBLIC_GUARDIAN_ENDPOINT=https://your-guardian.example
NEXT_PUBLIC_MIDEN_RPC_URL=devnet
NEXT_PUBLIC_MIDEN_NOTE_TRANSPORT_URL=devnet
NEXT_PUBLIC_MIDEN_REGISTRATION_CODE=guardianReplace the Guardian placeholder with a reachable 0.18.0-rc.2 instance with ECDSA support, configured for the same Miden devnet. Use the registration code accepted by that deployment. The pinned Miden SDK is 0.17.0-rc.4; keep the lockfile versions together. The root Docker Compose stack runs only the frontend; Guardian, RPC and note transport must be provided separately.
npm run devOpen http://localhost:3000. Restart the dev server after environment changes. A Ledger provides signatures; working Guardian/RPC/note-transport services and test funding are still required to execute the account flows.
- Connect by USB, unlock the device, and open its Ethereum app. Close Ledger Live and other apps/tabs holding the device connection.
- Open the app header's wallet dropdown → CONNECT LEDGER (USB) → Choose USB device. Select the device in the browser permission dialog.
- Choose an address layout. Ledger Live accounts uses
44'/60'/i'/0/0; Legacy / sequential addresses uses44'/60'/0'/0/i. The app shows five derived addresses at a time; Load more addresses fetches the next five. - Choose an address and confirm it on the physical Ledger. The header should show LEDGER ●. The wallet dropdown displays the chosen Ethereum address and offers Copy signer commitment.
- Create an ECDSA multisig or load one that already authorizes this Ledger's commitment. Use the copied commitment when adding this Ledger as a signer. The Ethereum address is not the Miden account ID or the signer commitment.
The device signs a transaction-summary hash, not decoded recipient/amount fields. Review those details in the app before approving. If the Ethereum app requires blind signing for these approvals, enable it only when intentionally testing this flow and record the setting with your results. Physical-device compatibility still needs validation; the teammate's earlier demo used a Nano X.
Start with a fresh 1-of-1 ECDSA account. Save its Miden account ID and the selected Ledger derivation path so you can reconnect to the same signer.
| Test | Actions | Expected result |
|---|---|---|
| Account creation | Connect Ledger, create the account, approve Guardian authentication prompts. | Created account contains the selected Ledger commitment. |
| Receive funds | Wait for the registration funding note, or use Retry funding. In Receive Funds, create a consume-notes proposal, Sign, then Execute. | Ledger approves the required signatures; the note is consumed and balance updates after confirmation. |
| Sync authentication | Click Sync with Ethereum open. Repeat and reject an authentication prompt. | Signed Guardian reads prompt on Ledger. Rejection fails the request without using local, Para or extension keys. Previously displayed data may remain visible. |
| Send funds | Send a small public note to a second test Miden account; Sign and Execute. Repeat with a private note. | Ledger approves both transactions. Recipient discovers/consumes each note; private delivery requires working note transport. |
| Add signer | Add a second test signer's commitment, retaining threshold 1 initially. Sign and Execute. | Updated account state includes the new signer. |
| Change threshold | With access to both signers, change threshold to 2; Sign and Execute under the old threshold. Create another proposal. | Account becomes 2-of-2; the next proposal requires both distinct signatures before execution. |
| Load account | Reload the page, reconnect to the same Ledger address, and Load Existing Account using the saved Miden ID. | Authentication and later transaction signatures still use Ledger. |
| Reject/cancel/unplug | Reject address confirmation; reject a signature; unplug during a prompt; reconnect and retry explicitly. | Unconfirmed addresses are not selected. Invalidated sessions cannot sign; no software-key fallback occurs. |
| Change address | Use Change Ledger Address and select another address. | Old account session clears; explicitly load an account authorizing the new commitment. |
While Ledger is the selected wallet source, every signature requested from that signer goes through Ledger, including Guardian authentication during sync. Plain Miden chain reads do not require signing. The Guardian's own co-signature is still generated by Guardian. Merely leaving USB plugged in does not force Ledger if you deliberately select a different wallet source.
Expect several sequential device approvals for some actions: authentication and transaction approval are separate messages. Keep the device unlocked and respond to each prompt. Cancel/disconnect invalidates the signing session; reconnect and reload explicitly. It cannot undo a transaction already submitted to the network.
Run these commands from bin/coordinator-frontend:
npm run test:ledger
npx playwright install chromium
npm run test:ledger:ui
npm run typecheck
npm run build
npm run test:ledger:app| Command | What it checks |
|---|---|
test:ledger |
Real Miden commitment derivation and EIP-712 signatures with a simulated Ledger; Guardian sync authentication, wrong-key/address rejection, cancellation and no software fallback. |
test:ledger:ui |
Production hook/dialog with a test-only device: address pagination/layouts, confirmation rejection/retry, signing, disconnect and address changes. |
test:ledger:app |
Built production app opens the Ledger dialog and loads the real USB SDK; no physical device selection or signing. |
test:ledger:services |
Real Guardian/Miden/transport execution with a simulated Ledger: create/load, receive, public/private send, add signer, threshold change and threshold enforcement. Requires separate service configuration below. |
The last verified software suite had 21 passing tests, plus 5 browser tests and 1 production-app smoke test. TypeScript and the production build passed. Physical Ledger signing and the real-service execution suite remain unverified. Automated simulated-device tests are not evidence that hardware signing works.
To run the real-service suite, provide actual compatible test-service endpoints:
export LEDGER_TEST_RPC_URL='http://localhost:57291'
export LEDGER_TEST_GUARDIAN_URL='http://localhost:3000'
export LEDGER_TEST_TRANSPORT_URL='http://localhost:50051'
export LEDGER_TEST_INVITATION_CODE='your-test-invitation'
npm run test:ledger:servicesThese ports are examples, not services started by the test command. Services
must allow browser requests and fund account registration with enough native
assets for transfers and fees. On an open local network without automatic
registration funding, fund the account ID printed by the runner during its
three-minute wait. The runner uses public deterministic test keys, so use
only disposable test funds. It checks on-chain state after execution, rather
than counting a proposal reaching ready as success. See the
detailed service-test guide
for additional options.
Connect Ledger → choose path/address → confirm address on device
→ validate public key/address → derive Miden signer commitment
→ create/load multisig with the Ledger-backed Eip712Signer
Transaction approval / signed Guardian request
→ Eip712Signer builds the protocol EIP-712 object
→ DirectLedgerAdapter checks schema/address and queues the request
→ Ethereum Signer Kit → WebHID → Ledger confirmation/signature
→ SDK verifies signature → Guardian / multisig execution flow
The SDK derives the commitment from the normalized secp256k1 public key using Miden WASM. Do not hash the Ethereum address or the displayed hex yourself. Public key → commitment is deterministic; commitment → public key is not reversible. Add Signer explicitly accepts a commitment rather than guessing what an arbitrary hex string represents.
The adapter accepts only three EIP-712 schemas, all with domain version 1:
| Primary type | Domain | Signed message field |
|---|---|---|
MidenTransaction |
Miden Transaction |
txSummaryHash: bytes32 |
GuardianRequest |
Guardian Request |
requestHash: bytes32 |
GuardianLookup |
Guardian Lookup |
lookupHash: bytes32 |
Guardian's SDK constructs these payloads, including timestamp-bound authentication hashes. Prompts are serialized, the selected path is fixed for the session, and invalidated sessions reject pending results. Authentication queued longer than 30 seconds fails for an explicit retry. The Ledger context module supplies no remote clear-signing descriptors, so this path needs no Ledger service credentials.
| File | Responsibility |
|---|---|
| device.ts | Real USB discovery/session, Ethereum Signer Kit, local context module, SDK action completion/cancellation/timeouts. |
| adapter.ts | EIP-1193 bridge accepting eth_signTypedData_v4, schema/address checks, prompt queue, session invalidation and signature normalization. |
| useLedgerSession.ts | Address pagination/layouts, on-device confirmation, selected signer and connection lifecycle. |
| LedgerPanel.tsx | Connection dialog, address picker and signing/cancellation status. |
| multisigApi.ts | Returns the selected Ledger signer for ECDSA; missing Ledger sessions fail instead of falling back to local keys. |
| MultisigContext.tsx | Binds the Ledger signer to create/load and shared account/proposal/sync operations; clears stale account sessions on wallet changes. |
| tests/ledger | Cryptographic, simulated-device, browser, production-app and live-service test runners. |
- USB unavailable: use Chrome/Edge on localhost or HTTPS and check WebHID policy. Device busy/missing: check cable, unlock/open Ethereum, close Ledger Live and other sessions, then close/reopen the connection dialog.
- Unexpected address: compare the layout and path; confirm on-device.
- Authentication timeout: check the computer clock, keep Ledger ready and explicitly retry the app action. Do not expect a fallback signature.
- No funds / transaction pending: check registration funding and that Guardian, RPC and note transport use the same network; then retry funding or manually sync.
Report the commit (git rev-parse HEAD), device model/firmware/Ethereum app
version, browser/OS, layout/path, action, exact error and blind-signing setting.
Share sanitized logs only—never recovery phrases, PINs, private keys, credentials
or complete signed authentication headers. More detail is available in the
frontend README
and integration guide.
This project is under active development. The APIs, data structures, and workflows are subject to change. Expect breaking changes as we iterate on the design and implementation.
The frontend communicates directly with OpenZeppelin Guardian for proposal coordination and state synchronization. No Rust coordinator server or PostgreSQL database is required.
Frontend (Next.js + WASM)
├── WebClient (@miden-sdk/miden-sdk) ──────► Miden RPC Node
├── Multisig (@openzeppelin/miden-multisig-client) ──► Guardian Endpoint
└── Para Wallet (@getpara/react-sdk-lite) ──► External ECDSA wallets
- coordinator-frontend — Next.js web application. Runs a WASM-compiled Miden client in the browser, connects to Guardian for proposal coordination, and supports multiple wallet types for signing.
- miden-multisig-client — Standalone Rust client library for multisig operations. It is not used by the frontend.
The frontend supports four wallet sources for signing:
| Source | Scheme | Description |
|---|---|---|
| Ledger USB | ECDSA / EIP-712 | Direct WebHID connection; user selects and confirms an address |
| Local keys | Falcon / ECDSA | Browser-generated keys stored in IndexedDB |
| Para | ECDSA | External EVM wallets (MetaMask, etc.) via Para SDK |
| Miden Wallet | Falcon / ECDSA | Miden Wallet browser extension |
.
├── bin/
│ └── coordinator-frontend/ # Next.js web frontend (Guardian SDK + WASM)
├── crates/
│ ├── miden-multisig-client/ # Standalone Rust client library
│ └── test-utils/ # Rust client mock-chain test helpers
├── Dockerfile.coordinator-frontend # Docker image for frontend
└── docker-compose.yml # Frontend only; external Guardian and Miden services
Configure the frontend environment with a compatible Guardian endpoint, then start the frontend:
# Only if you do not already have .env.local:
cp bin/coordinator-frontend/.env.example bin/coordinator-frontend/.env.local
# Set NEXT_PUBLIC_GUARDIAN_ENDPOINT in .env.local before building.
make docker-run-frontendThis builds the frontend image and starts the web UI at http://localhost:3000.
It does not start Guardian, a Miden node or note transport.
To stop and remove the frontend container:
make docker-stop-frontendThe frontend is configured via NEXT_PUBLIC_* environment variables, set at build time. Docker Compose passes them as build arguments from bin/coordinator-frontend/.env.local. Rebuild the image after changing them.
| Variable | Description | Default |
|---|---|---|
NEXT_PUBLIC_GUARDIAN_ENDPOINT |
Guardian service URL for proposal coordination | (required) |
NEXT_PUBLIC_MIDEN_RPC_URL |
Miden node RPC URL or SDK network shorthand | devnet |
NEXT_PUBLIC_MIDEN_NOTE_TRANSPORT_URL |
Note transport URL or SDK network shorthand | devnet |
NEXT_PUBLIC_MIDEN_REGISTRATION_CODE |
Devnet account-registration invitation code | guardian |
NEXT_PUBLIC_PARA_API_KEY |
Para wallet API key (enables Para wallet support) | (empty — Para disabled) |
NEXT_PUBLIC_PARA_ENVIRONMENT |
Para environment (development or production) |
development |
VITE_PARA_API_KEYis also accepted as a compatibility alias forNEXT_PUBLIC_PARA_API_KEY.
- Node.js 20.19+
- A compatible Guardian endpoint and Miden services
- Docker (optional, for containerized frontend setup)
- Rust 1.90+ (only for standalone Rust client development; see
rust-toolchain.toml)
# Check which tools are installed
make check-tools
# Install all required dev tools (typos, nextest, taplo, machete)
make install-toolscd bin/coordinator-frontend
npm install
npm run dev
# → http://localhost:3000Create a .env.local file for local development:
NEXT_PUBLIC_GUARDIAN_ENDPOINT=https://your-guardian.example
NEXT_PUBLIC_MIDEN_RPC_URL=devnet
NEXT_PUBLIC_MIDEN_NOTE_TRANSPORT_URL=devnet
NEXT_PUBLIC_MIDEN_REGISTRATION_CODE=guardian
NEXT_PUBLIC_PARA_API_KEY=<your-para-api-key>
NEXT_PUBLIC_PARA_ENVIRONMENT=developmentmake build # Build all Rust crates (release)
make test # Run standalone Rust client tests
make lint # Run all linters (clippy, fmt, taplo, typos, machete)
make check # Check all targets for errorsThis project is MIT licensed.