Ledger as guardian
Browser (WebHID) and Node (USB), blind signing off, and what the device shows.
Set up the device
- Update the Ethereum app in Ledger Live. Close Ledger Live afterwards; it holds the USB device.
- In the Ethereum app settings leave Blind signing off. Mandate never needs it; approvals are plain text.
- Find the address: Ledger Live → Ethereum account → Receive, or click Approve on Ledger once in the console; the error names the connected address if it does not match the on-chain guardian.
- Point the account at it on both chains:
cast send $ACCOUNT "setGuardian(address)" $LEDGER --private-key $OWNER_PRIVATE_KEY --rpc-url $BASE_SEPOLIA_RPC
cast send $ACCOUNT "setGuardian(address)" $LEDGER --private-key $OWNER_PRIVATE_KEY --rpc-url $ARC_TESTNET_RPCLeave GUARDIAN_PRIVATE_KEY, MANDATE_GUARDIAN_KEY and NEXT_PUBLIC_DEV_GUARDIAN_KEY empty.
In the browser (console)
The console uses Ledger's Device Management Kit over WebHID, which works in Chromium browsers on HTTPS or localhost. The approval sheet:
- fetches the approval text from the server (built once per plan, step and nonce, so refreshes and second tabs show the same text);
- opens the device, checks the Ethereum app is open, and shows the exact text on screen;
- calls
signPersonalMessage; the Ledger paginates the nine lines; - posts
{ planId, step, signature }; the server recovers the signer and compares it to the on-chain guardian.
import { ledgerWebGuardian } from "@yashjain99/mandate-sdk/ledger-web";
const guardian = ledgerWebGuardian(); // derivation path 44'/60'/0'/0/0 by default
const signature = await guardian.signMessage(text, (status) => console.log(status));In Node (scripts, servers)
import { createMandateFromEnv, ledgerNodeGuardian } from "@yashjain99/mandate-sdk";
const client = createMandateFromEnv({ guardian: ledgerNodeGuardian() }); // USB via node-hid
await client.executeAll(plan); // guardian steps prompt the deviceOptional peers: @ledgerhq/device-management-kit, @ledgerhq/device-signer-kit-ethereum, @ledgerhq/device-transport-kit-node-hid (Node) or -web-hid (browser), rxjs.
What the device shows
Mandate approval
Account: 0x58612ce0945666cf58ca7e24808625a3cf10c5c3
Chain: 84532
Plan: 0x…
Step: 2
Max USDC out: 10.000000
Calls: 0x…
Deadline: 1788561704
Nonce: 3Read Chain, Step and Max USDC out before approving. The console shows the same text beside a plain-English description of the step.
Why not EIP-712 or calldata
Mandate's contracts have no Crypto Assets List descriptors, so a contract call would require blind signing (SW 0x6a80 when it is disabled). EIP-712 without registered filters also falls back to blind signing. Personal messages are clear-signed in full. Feedback on this is recorded in the Ledger sponsor page.
Troubleshooting
| Symptom | Fix |
|---|---|
| Device not listed in the Chrome prompt | close Ledger Live; unlock; open the Ethereum app; use Chrome, Edge or Brave |
BadGuardianSignature after approving | the device's address differs from guardian() on that chain; run setGuardian on both chains |
| Signature rejected as stale | another guardian step consumed the nonce or 15 minutes passed; refresh and sign again |
| Nothing on the device screen | the Ethereum app is not open, or the device locked |