- Add AML scope note to Handoff - RN Multichain Probe (sanctions-only vs full KYT) - Add human-blocked section with 3 precise next steps for owner - Create Task 11 Pre-flight Inventory: library choice, dev/prod flow, admin UI gaps, backend gaps, risks, acceptance criteria
12 KiB
Task #11 Pre-flight Inventory — Trezor Signing for Admin Actions
Status: Findings / design review — do not implement until human probes for #7C and #8 are complete.
Date: 2026-05-28
Scope: Hardware-wallet signing for sweep, release, and refund admin actions. Backend already has xpub derivation, registration, and message-formatting infrastructure. This inventory covers what is missing on the frontend and what the end-to-end flow looks like.
1. Library choice: @trezor/connect-web
Option matrix
| Library | Maturity | Browser support | Bundle size | Recommendation |
|---|---|---|---|---|
@trezor/connect-web |
Official, actively maintained by SatoshiLabs | Chrome/Edge/Brave (WebUSB); Firefox requires Trezor Bridge | ~200 KB compressed | ✅ Use this |
trezor-connect (legacy) |
Deprecated, v8 frozen | Same as above | Larger | ❌ Do not use — no longer updated |
@trezor/connect (node/headless) |
For server-side or Electron | N/A (no browser popup) | Smaller | ❌ Wrong environment — we need browser UI |
Why @trezor/connect-web
- The Trezor team consolidated on
@trezor/connect-webas the single browser SDK. It injects a secure iframe fromhttps://connect.trezor.io/<version>/iframe.htmland opens a trusted popup for device interaction. - WebUSB works on Chromium-based browsers (Chrome, Edge, Brave, Arc) without any native software. Firefox falls back to Trezor Bridge, which most admin users already have installed via Trezor Suite.
- The API surface is promise-based and Typescript-friendly:
import TrezorConnect from '@trezor/connect-web'; await TrezorConnect.init({ lazyLoad: true, manifest: { email: 'dev@amn.gg', appUrl: 'https://dev.amn.gg' }, }); const result = await TrezorConnect.ethereumSignTransaction({ path: "m/44'/60'/0'/0/0", transaction: { to: '0x...', value: '0x0', gasPrice: '0x...', gasLimit: '0x...', nonce: '0x...', chainId: 56, data: '0x...', // ERC-20 transfer or contract call }, }); - Mobile is out of scope — WebUSB does not work on iOS Safari, and Android support is spotty. Admin actions are desktop-only by design.
Installation
cd frontend && npm install @trezor/connect-web
2. Dev vs. prod signing flow — end-to-end
Current state (backend already shipped)
The backend has a complete TrezorAccount model, xpub-based HD derivation, registration challenge/response, and operation-message formatting. The releaseRefundService already calls assertTrezorSignatureForOperation() when TREZOR_SAFEKEEPING_REQUIRED=true. The sweepService has a SweepSigner abstraction with a HotKeySweepSigner and a BuildOnlySigner (returns the tx without signing). What is missing is a TrezorSweepSigner and the frontend connector.
Proposed dev/prod flow
Step A — Admin registers Trezor (already works backend-only)
- Admin opens
/dashboard/admin/trezor-register. - Frontend calls
TrezorConnect.getPublicKey({ coin: 'ETH', path: "m/44'/60'/0'/0" }). - Device shows popup; admin confirms.
- Frontend receives
xpub+ first derived address (m/44'/60'/0'/0/0). - Frontend calls
GET /api/trezor/registration-message?xpub=...®istrationAddress=.... - Frontend calls
TrezorConnect.signMessage({ path: "m/44'/60'/0'/0/0", message: <challenge>, coin: 'ETH' }). - Frontend
POST /api/trezor/registerwith xpub, registration address, proof message, and proof signature. - Backend verifies and stores the account.
Step B — Admin triggers a sweep/release/refund
- Admin opens
/dashboard/admin/sweeps(or release/refund UI) and clicks "Execute sweep" on a pending destination. - Frontend calls
POST /api/admin/actions/build-tx(new endpoint needed) with:{ "action": "sweep", "destinationId": "...", "chainId": 56 } - Backend builds the unsigned transaction (same logic as
BuildOnlySigner), estimates gas, computes nonce, and returns:{ "unsignedTx": { "to": "0x...", "data": "0x...", "value": "0x0", "gasLimit": "0x...", "gasPrice": "0x...", "nonce": 42, "chainId": 56 }, "derivationPath": "m/44'/60'/0'/0/7", "txIntentHash": "0x..." } - Frontend displays a confirmation modal showing:
- From address (derived from xpub at the returned path)
- To address
- Token + amount
- Network
- Gas estimate
- Admin clicks "Sign with Trezor".
- Frontend calls
TrezorConnect.ethereumSignTransaction({ path, transaction: unsignedTx }). - Device shows popup with tx details; admin physically confirms on device.
- Frontend receives the signed transaction bytes (
result.payload.serializedTx). - Frontend broadcasts via wagmi's
sendTransaction({ raw: serializedTx })or ethersprovider.broadcastTransaction(serializedTx). - After broadcast, frontend calls
POST /api/admin/actions/confirm-txwith:{ "action": "sweep", "destinationId": "...", "txHash": "0x...", "trezor": { "message": "Amanat escrow Trezor transaction approval\n...", "signature": "0x..." } } - Backend verifies the Trezor signature against the registered xpub, appends the ledger entry, and marks the sweep complete.
Key design decisions to review
| Decision | Option A (recommended) | Option B |
|---|---|---|
| Who broadcasts? | Browser (wagmi/ethers) — backend never sees raw signed bytes | Backend receives signed tx and broadcasts |
| Why A? | Backend holding a signed tx is almost as sensitive as holding a private key. Browser broadcast keeps the signature in userland. | Simpler for unreliable browser networks, but increases backend attack surface. |
| Message signing vs tx signing | Use ethereumSignTransaction for actual sweeps; use signMessage for the registration proof and for release/refund operation intents |
Use signMessage for everything — but then backend must reconstruct and verify the tx hash, which is fragile |
| Derivation path discovery | Backend tells frontend which path to use (from DerivedDestination record). Frontend does not iterate. |
Frontend derives addresses from xpub locally to find the right one — more client-side code, more exposure |
3. Admin UI surface needed
New pages / sections
| Route | Purpose | Admin role |
|---|---|---|
/dashboard/admin/trezor-register |
Register a Trezor xpub, verify first derived address, label device | superadmin |
/dashboard/admin/trezor-status |
Show registered device, xpub fingerprint, derived addresses in use, last activity | superadmin |
/dashboard/admin/sweeps |
List pending derived destinations awaiting sweep; "Build tx" → "Sign with Trezor" → "Broadcast" flow | admin |
/dashboard/admin/pending-actions |
NEW — unified queue of all actions awaiting Trezor signature (sweeps, releases, refunds). Shows who requested, when, amount, and a "Sign now" button. | admin |
/dashboard/admin/pending-actions — the critical new UI
This is the biggest gap. Today, sweeps are either cron-fired or triggered ad-hoc. With Trezor, every sweep becomes a human-in-the-loop action because the device must be present to sign. The admin needs a queue.
Proposed UI elements:
-
Pending queue table
- Columns: Action type (sweep / release / refund), Payment/Destination ID, Amount + token, Chain, Requested by, Requested at, Status (
pending_signature/signed_broadcasting/confirmed/failed) - Row actions: "View tx details", "Sign with Trezor", "Cancel" (superadmin only)
- Columns: Action type (sweep / release / refund), Payment/Destination ID, Amount + token, Chain, Requested by, Requested at, Status (
-
Tx detail modal
- Shows the unsigned tx JSON in human-readable form (from, to, token, amount, gas)
- Shows the derivation path and how it maps to the registered Trezor
- "Sign with Trezor" button → triggers
@trezor/connect-webflow
-
Signing state machine
idle→building_tx→awaiting_device(popup open) →signing(user confirming on device) →broadcasting→confirmed/failed- Each state shows a distinct UI indicator so the admin knows the device is waiting for them
-
Break-glass override
- A "Use hot-key override" button visible only to
superadmin - Clicking it shows a warning: "This bypasses Trezor safekeeping and triggers a Telegram alarm. Are you sure?"
- If confirmed, frontend calls
POST /api/admin/actions/break-glasswhich toggles hot-key signing for 1 hour and sends alarm
- A "Use hot-key override" button visible only to
Components to build (frontend)
frontend/src/sections/admin/trezor/
trezor-register-view.tsx # Registration flow
trezor-status-view.tsx # Device status + derived addresses
pending-actions-view.tsx # Queue of actions awaiting signature
trezor-sign-modal.tsx # Tx detail + sign button + state machine
hooks/
useTrezorConnect.ts # Wraps @trezor/connect-web init + methods
useTrezorSignTransaction.ts # Handles ethereumSignTransaction flow
usePendingActions.ts # Polls /api/admin/pending-actions
4. Backend gaps to fill (minor)
The backend is ~70% complete for Trezor. Remaining work:
| Gap | Effort | Notes |
|---|---|---|
POST /api/admin/actions/build-tx |
Small | Reuses BuildOnlySigner logic; returns unsigned tx + derivation path |
POST /api/admin/actions/confirm-tx |
Small | Reuses existing releaseRefundService / sweep confirmation; adds Trezor proof verification |
POST /api/admin/actions/break-glass |
Small | Toggles env override for 1h, sends Telegram alarm, logs audit entry |
GET /api/admin/pending-actions |
Small | Queries DerivedDestination (status=awaiting_sweep) + Payment (status=awaiting_release/awaiting_refund) |
TrezorSweepSigner class |
Small | Implements SweepSigner interface; instead of signing, it queues the action and returns a "pending signature" result |
| Admin authorization on new routes | Tiny | Reuse existing authorizeRoles(['admin', 'superadmin']) |
5. Risk notes
- WebUSB reliability: Some users report
Transport_Missingerrors even on Chrome when the Trezor Bridge is also installed. The fix is to uninstall Bridge and rely purely on WebUSB, or to ensure the Bridge daemon is running. We should document this in the admin setup guide. - Trezor Model One vs Model T vs Safe 3/5:
@trezor/connect-webabstracts all models. The only visible difference is whether the user confirms on buttons (Model One) or touchscreen (Model T/Safe). No code change needed. - Passphrase wallets: If the admin uses a passphrase-protected hidden wallet, the passphrase must be entered in the Trezor popup. Our code does not need to handle this — it's part of the SDK popup flow.
- Multi-admin (m-of-n): Out of scope for v1. The current
TrezorAccountmodel stores one xpub per user. A future v2 could store multiple registered devices and requiretsignatures beforeconfirm-txsucceeds. Thepending-actionsqueue UI is designed to accommodate this (shows "1 of 2 signatures collected").
6. Suggested acceptance criteria (for implementation PR)
- Admin can register a Trezor and
/api/trezor/accountreturnsregistered: true - Admin can view a pending-actions queue with ≥1 sweep/release/refund awaiting signature
- Clicking "Sign with Trezor" opens the Trezor popup, displays the tx, and returns a signature
- Signed tx is broadcast from the browser and hash is reported to backend
- Backend verifies Trezor proof before confirming the action
- Break-glass toggle works and fires Telegram alarm
- Audit log captures: admin user, Trezor address, tx hash, before/after escrow state
- Without Trezor proof and with
TREZOR_SAFEKEEPING_REQUIRED=true, release/refund/sweep is rejected