[WA-3243] Hardware-wallet errors reach users as "unknown": preserve and translate Ledger device errors (web) - #8568
Conversation
Signing with a Ledger surfaced "An unknown RPC error occurred. Details:
unknown (info={ "_tag": "InvalidStatusWordError", ... }, code=UNKNOWN_ERROR,
version=6.17.0) Version: viem@2.52.2" — the device's own explanation was
destroyed and library internals took its place.
`InvalidStatusWordError` has no `message`; its reason lives in an
`originalError` that serialises to `{}`, and it carries no `errorCode`, so the
6985 rejection branch could never match it. The mapper fell back to the literal
string 'unknown', which also made `normalizeError` classify every hardware
wallet failure as `unknown` instead of `ledger_error`.
- Add `services/onboard/ledger-errors.ts`: read the reason from `_tag`,
`originalError.message` and the status word (wherever the kit put it),
classify it, and translate the known device states into plain sentences.
The raw evidence rides in the error's `info`, which reaches Datadog through
the serialised message but is never rendered — Mixpanel keeps receiving
taxonomy enums only.
- Classify a rejection from any DMK error class as `ACTION_REJECTED`, so a
cancellation stops being reported as a failure.
- Show the translated sentence, and withhold the raw payload, in
`TxSubmitError`, `ErrorMessage` and both notification hooks. An unmapped
device state gets one fallback sentence plus a support reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ybxq8EGntf1FmNC7Q2kLz1
Branch preview✅ Deploy successful! Storybook: |
📦 Next.js Bundle Analysis for @safe-global/webThis analysis was generated by the Next.js Bundle Analysis action. 🤖
|
| Page | Size (compressed) |
|---|---|
global |
1.53 MB (🟡 +38.63 KB) |
Details
The global bundle is the javascript bundle that loads alongside every page. It is in its own category because its impact is much higher - an increase to its size means that every page on your website loads slower, and a decrease means every page loads faster.
Any third party scripts you have added directly to your app using the <script> tag are not accounted for in this analysis
If you want further insight into what is behind the changes, give @next/bundle-analyzer a try!
Fifty-six Pages Changed Size
The following pages changed size from the code in this PR compared to its base branch:
| Page | Size (compressed) | First Load |
|---|---|---|
/403 |
635 B (🟢 -86 B) |
1.53 MB |
/404 |
837 B (🟡 +19 B) |
1.53 MB |
/_offline |
1.05 KB (🟢 -98 B) |
1.53 MB |
/addOwner |
551 B (🟢 -1 B) |
1.53 MB |
/address-book |
23.07 KB (🟢 -3.55 KB) |
1.55 MB |
/apps |
44.98 KB (🔴 +25.33 KB) |
1.57 MB |
/apps/custom |
42.42 KB (🔴 +26.06 KB) |
1.57 MB |
/apps/open |
9.27 KB (🟡 +963 B) |
1.54 MB |
/balances |
25.5 KB (🟡 +377 B) |
1.55 MB |
/balances/nfts |
16.29 KB (🔴 +5.39 KB) |
1.55 MB |
/balances/positions |
5.96 KB (🔴 +3.75 KB) |
1.54 MB |
/cookie |
7.33 KB (🟢 -48 B) |
1.54 MB |
/dashboard/new |
1.42 KB (🟢 -57 B) |
1.53 MB |
/earn |
575 B (🟡 +7 B) |
1.53 MB |
/home |
195.68 KB (🟢 -125.88 KB) |
1.72 MB |
/hypernative/oauth-callback |
2.07 KB (🟡 +58 B) |
1.53 MB |
/imprint |
1.42 KB (🟢 -9 B) |
1.53 MB |
/licenses |
1.96 KB (🟢 -94 B) |
1.53 MB |
/new-safe/advanced-create |
30.99 KB (🟡 +265 B) |
1.56 MB |
/new-safe/create |
29.46 KB (🟢 -67 B) |
1.56 MB |
/new-safe/load |
9.32 KB (🟢 -210 B) |
1.54 MB |
/privacy |
13.21 KB (🟢 -16 B) |
1.54 MB |
/settings/appearance |
6.17 KB (🔴 +3.81 KB) |
1.54 MB |
/settings/cookies |
6 KB (🔴 +3.78 KB) |
1.54 MB |
/settings/data |
32.36 KB (🟡 +3.56 KB) |
1.56 MB |
/settings/environment-variables |
7.26 KB (🔴 +3.5 KB) |
1.54 MB |
/settings/modules |
6.92 KB (🔴 +3.62 KB) |
1.54 MB |
/settings/notifications |
11.42 KB (🔴 +3.82 KB) |
1.54 MB |
/settings/safe-apps |
8.88 KB (🔴 +3.65 KB) |
1.54 MB |
/settings/security |
6.49 KB (🔴 +3.66 KB) |
1.54 MB |
/settings/setup |
37.76 KB (🟡 +696 B) |
1.57 MB |
/share/safe-app |
6.63 KB (🟢 -169 B) |
1.54 MB |
/spaces |
611 B (🟡 +2 B) |
1.53 MB |
/spaces/activity |
546 B (🟡 +1 B) |
1.53 MB |
/spaces/address-book |
509 B (🟡 +1 B) |
1.53 MB |
/spaces/create-space |
440 B (🟡 +1 B) |
1.53 MB |
/spaces/members |
499 B (🟡 +2 B) |
1.53 MB |
/spaces/safe-accounts |
507 B (🟢 -2 B) |
1.53 MB |
/spaces/security |
6.75 KB (🟢 -121.93 KB) |
1.54 MB |
/spaces/settings/about |
508 B (🟡 +3 B) |
1.53 MB |
/spaces/settings/account |
509 B (🟡 +4 B) |
1.53 MB |
/spaces/settings/general |
509 B (🟡 +2 B) |
1.53 MB |
/stake |
1.22 KB (🟡 +6 B) |
1.53 MB |
/swap |
744 B (🟡 +13 B) |
1.53 MB |
/terms |
13.71 KB (🟢 -19 B) |
1.54 MB |
/transactions |
35.4 KB (🟢 -19.96 KB) |
1.56 MB |
/transactions/history |
35.36 KB (🟢 -19.96 KB) |
1.56 MB |
/transactions/messages |
14.17 KB (🟡 +2.67 KB) |
1.54 MB |
/transactions/msg |
6.28 KB (🟢 -1.85 KB) |
1.54 MB |
/transactions/queue |
6.22 KB (🔴 +3.76 KB) |
1.54 MB |
/transactions/tx |
1.01 KB (🟡 +1 B) |
1.53 MB |
/wc |
586 B (🟡 +1 B) |
1.53 MB |
/welcome/accounts |
610 B (🟢 -1 B) |
1.53 MB |
/welcome/invite-members |
445 B (🟡 +3 B) |
1.53 MB |
/welcome/select-safes |
448 B (🟡 +2 B) |
1.53 MB |
/welcome/spaces |
308 B (🟡 +1 B) |
1.53 MB |
Details
Only the gzipped size is provided here based on an expert tip.
First Load is the size of the global bundle plus the bundle for the individual page. If a user were to show up to your website and land on a given page, the first load size represents the amount of javascript that user would need to download. If next/link is used, subsequent page loads would only need to download that page's bundle (the number in the "Size" column), since the global bundle has already been downloaded.
Any third party scripts you have added directly to your app using the <script> tag are not accounted for in this analysis
Next to the size is how much the size has increased or decreased compared with the base branch of this PR. If this percentage has increased by 20% or more, there will be a red status indicator applied, indicating that special attention should be given to this.
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟢 | Statements | 85.06% (-0.1% 🔻) |
35378/41594 |
| 🟡 | Branches | 70.03% (-0.27% 🔻) |
11600/16564 |
| 🟡 | Functions | 73.46% (-0.09% 🔻) |
5397/7347 |
| 🟢 | Lines | 86.3% (-0.1% 🔻) |
31664/36690 |
Show new covered files 🐣
St.❔ |
File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| 🟢 | ... / ledger-errors.ts |
100% | 100% | 100% | 100% |
| 🟢 | ... / UpsertProposer.tsx |
82.05% | 60.87% | 60% | 83.04% |
| 🟢 | ... / useTxNotifications.ts |
83.33% | 67.19% | 90% | 83.53% |
| 🟢 | ... / accountSelectAlert.ts |
92.31% | 60% | 100% | 100% |
Show files with reduced coverage 🔻
St.❔ |
File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| 🟡 | ... / utils.ts |
77.68% (-3.57% 🔻) |
62.32% (-2.9% 🔻) |
72.73% (-4.55% 🔻) |
79.17% (-3.13% 🔻) |
| 🟢 | ... / constants.ts |
58.33% (-8.33% 🔻) |
100% | 100% | 100% |
| 🟢 | ... / rtkQuery.ts |
100% (+2.56% 🔼) |
94.74% (-1.26% 🔻) |
100% | 100% |
| 🟡 | ... / transactions.ts |
58.28% | 29.31% (-1.72% 🔻) |
35.71% | 60.16% |
| 🟢 | ... / index.ts |
81.25% (-6.25% 🔻) |
100% | 100% | 100% |
| 🟢 | ... / index.tsx |
100% | 71.43% (-28.57% 🔻) |
100% | 100% |
| 🟢 | ... / SeverityIcon.tsx |
100% | 80% (-20% 🔻) |
100% | 100% |
| 🟢 | ... / useAllAddressBooks.ts |
89.47% (-1.05% 🔻) |
70.37% | 80% (-5% 🔻) |
93.59% |
| 🟢 | ... / ExpandableTransactionItem.tsx |
100% | 87.5% (-4.17% 🔻) |
100% | 100% |
| 🟢 | ... / index.ts |
58.33% (-5.95% 🔻) |
100% | 100% | 100% |
| 🟢 | ... / index.tsx |
93.66% (-0.38% 🔻) |
91.3% (-0.36% 🔻) |
89.19% (-0.28% 🔻) |
95.87% (-0.26% 🔻) |
| 🟡 | ... / EditProposerDialog.tsx |
56.52% (-8.18% 🔻) |
0% | 0% | 61.9% (-6.85% 🔻) |
| 🟢 | ... / useSubmitDelegation.ts |
96.97% (-0.09% 🔻) |
85.71% (+5.71% 🔼) |
100% | 96.88% (-0.09% 🔻) |
| 🟡 | ... / useTransactionType.tsx |
66.07% (-33.93% 🔻) |
37.74% (-58.1% 🔻) |
100% | 64.81% (-35.19% 🔻) |
| 🟢 | ... / index.tsx |
100% | 66.67% (-33.33% 🔻) |
100% | 100% |
| 🟢 | ... / index.tsx |
100% | 71.43% (-4.76% 🔻) |
100% | 100% |
| 🔴 | ... / index.tsx |
53.57% (-35.32% 🔻) |
0% (-33.33% 🔻) |
0% (-66.67% 🔻) |
57.69% (-30.31% 🔻) |
| 🟢 | ... / index.tsx |
86.96% (-0.28% 🔻) |
56.25% (-8.04% 🔻) |
33.33% | 92.86% (-0.17% 🔻) |
| 🟡 | ... / AddManually.tsx |
62.22% (-6.67% 🔻) |
33.33% | 11.11% (-33.33% 🔻) |
64.29% (-4.76% 🔻) |
Test suite run success
7759 tests passing in 881 suites.
Report generated by 🧪jest coverage report action from 74a84cb
…(WA-3243) Review round 1 follow-ups. `CONNECTION_TAGS` listed `OpeningConnectionError`, which is the SDK's export name, not the runtime discriminator: the class declares `_tag = 'ConnectionOpeningError'`, so the entry could never match and a failed connection attempt fell through to the fallback sentence. Every one of the nine transport tags was re-read off the shipped class (and the eth-app, global and open-app tables re-checked the same way); this was the only mismatch. All nine are now pinned by a table test, plus one asserting the export name does not map. `useTxNotifications` had no test file at all, so the ledger override and the `detailedMessage` suppression there were unguarded. Added a colocated suite covering the generic path, both Ledger surfaces, a cancellation and the mined-revert precedence. Also lower-case the status word before comparing (insurance — the kit emits lower-case hex today) and record why a status word nested in `originalError` is read at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ybxq8EGntf1FmNC7Q2kLz1
Review round 1 verdict: approve-with-nits. Records the two confirmed findings sent back to the dev agent, and the 0x6982 lead being refuted against the shipped Ledger SDK. Also records that the failing `test` check on PR #8568 is inherited from dev: NEXT_PUBLIC_SAFENET_EXPLORER_URL is read in code but absent from the CI env manifest and every workflow env block. The files that test depends on are byte-identical to origin/dev on this branch. Not fixed here, per the CI-config guardrail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ybxq8EGntf1FmNC7Q2kLz1
|
…edger-device-errors
Types-only move, no behaviour change. `LedgerDeviceErrorReason` and `LedgerDeviceErrorInfo` now live in `services/onboard/types.ts`, matching the sibling `onboard/trezor/types.ts`. `LEDGER_ERROR_SOURCE` stays in `ledger-errors.ts` — it is runtime state, and keeping it there leaves `types.ts` a pure-types module like its sibling. The interface declares the field as the literal `'ledger-device'`, and the const is annotated `LedgerDeviceErrorInfo['source']` so the two cannot drift apart without a compile error. That marker is what lets `getLedgerDeviceError` find the payload after viem re-wraps the error, so silent drift would disable the feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ybxq8EGntf1FmNC7Q2kLz1
`mapLedgerError` built its error with ethers' `makeError`, which appends
every `info` key plus `code=` and `version=` to `message` and keeps the
clean sentence only in `shortMessage`. `message` is what gets rendered —
`@web3-onboard/hw-common`'s account picker prints it verbatim and we
cannot intercept it — so the translated sentence reached users as
Unlock your Ledger and try again. (info={ "deviceMessage": "Device
locked.", ... }, code=UNKNOWN_ERROR, version=6.17.0)
The error is now built by hand: the sentence IS the message, and the
device's own words ride on `info`, which no renderer touches.
That leaves the debugging sinks without evidence, since it used to reach
Datadog only by being baked into the message. `CodedException` now reads
it off the thrown error's cause chain and emits it as queryable tags
(`ledger_reason`, `ledger_tag`, `ledger_status_word`,
`ledger_device_message`) — better than a string blob, and Mixpanel keeps
receiving enums only.
A rejection no longer says `user rejected action` either. It could not
simply be reworded: `matchUserOutcome` classifies purely on message text,
and a rejection that stops matching starts counting as a failure
(WA-2950). `You rejected the request on your Ledger.` reads as copy and
still hits the matcher's `rejected the request` branch.
The suite asserted the opposite of all this — `expect(error.message)
.toContain('InvalidStatusWordError')`, commented as "must survive
serialisation into `error.message`". That assertion is what let the bug
ship: purity was checked on `getLedgerUserMessage()`'s output but never
on the `Error.message` renderers actually show.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hardware-wallet account picker is `@web3-onboard/hw-common`'s own Svelte widget: we choose the words it shows, but not how it draws them, and its default for a scan failure is bare red text wedged into the control bar between the checkbox and the Scan Accounts button. The widget mounts in a shadow root with `mode: 'open'`, and that is the seam. We append one stylesheet to it that re-skins `.error-msg` in place as the app's destructive Alert — tinted surface, rounded corners, the same lucide `circle-alert`, painted through a CSS mask so it takes the palette colour. The element keeps its slot and its own positioning. Two details: * Svelte scopes its rule as `.error-msg.svelte-<hash>`, and that hash changes whenever they rebuild the package. The override repeats the class to outrank it without ever naming the hash. * The bar is pinned at `height: 3.5rem`, which a padded, tinted box on three lines spills past (69px in a 56px content box). It now gets `height: auto; min-height: 3.5rem` — nothing moves, the bar grows. The palette is hardcoded because those tokens live on `.shadcn-scope`, applied to wrappers inside the React tree; the picker is appended to `<body>`, outside every one of them. Text colour still comes from a `:root` token, which does inherit through the shadow boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What it solves
Resolves: WA-3243
Signing with a Ledger on web could fail with this toast:
Two defects in that one string:
Root cause was
mapEthersErrorinledger-module.ts:InvalidStatusWordErrorhas nomessage— its text lives inoriginalError, anError, which serialises to{}. So we emitted the literal stringunknown. It also carries noerrorCode, so the6985→ACTION_REJECTEDbranch could never match it, and genuine user cancellations were reported as failures.How this PR fixes it
services/onboard/ledger-errors.tsreads what the device actually said —_tag, the message frommessageor the nestedoriginalError.message, and the status word fromerrorCodeor the nestedoriginalError.errorCode(UnknownDeviceExchangeErrornests it). Status words and tags are taken from the shipped SDKs, not guessed:@ledgerhq/device-management-kitglobal errors and the transport family,@ledgerhq/device-signer-kit-ethereumapp errors,OpenAppCommand. Each of the nine transport tags was read off the runtime class, which caught one export-name/_tagmismatch (OpeningConnectionErrordeclares_tag = 'ConnectionOpeningError').Known states translate to plain sentences — rejected / locked / app closed / blind signing / connection lost — plus one fallback sentence with a support reference (
LEDGER-0x<status word>) for states we have no sentence for. A rejection is classified asACTION_REJECTEDregardless of which DMK class carries it, so cancellations stop being counted as failures.The error is built by hand, not by
makeErrorethers'
makeErrorappends everyinfokey pluscode=andversion=tomessage, keeping the clean sentence only inshortMessage.messageis what actually gets rendered — and one renderer is outside our reach:@web3-onboard/hw-common's account picker prints it verbatim. Going throughmakeErrortherefore deliveredbuildLedgerErrorconstructs the error directly instead: the sentence is the message, and the device's own words ride oninfo, which no renderer touches.That leaves the debugging sinks without evidence, since the payload previously reached Datadog only by being baked into the message.
CodedExceptionnow recovers it from the thrown error's cause chain and emits it as queryable tags —ledger_reason,ledger_tag,ledger_status_word,ledger_device_message— which is better than a string blob. Mixpanel keeps receiving taxonomy enums only.A rejection also no longer says
user rejected action. It could not simply be reworded:matchUserOutcomeclassifies purely on message text, and a rejection that stops matching starts counting as a failure (WA-2950). "You rejected the request on your Ledger." reads as copy and still hits the matcher'srejected the requestbranch.The account picker's scan error
@web3-onboard/hw-common's picker is its own Svelte widget: we choose the words, not how it draws them, and its default for a scan failure is bare red text wedged into the control bar. It mounts in a shadow root withmode: 'open', which is the seam —services/onboard/accountSelectAlert.tsappends one stylesheet that re-skins.error-msgin place as the app's destructive Alert, with the same lucidecircle-alertpainted through a CSS mask so it takes the palette colour.Two details worth knowing for future maintenance: Svelte scopes its rule as
.error-msg.svelte-<hash>and that hash changes whenever the package is rebuilt, so the override repeats the class to outrank it without naming the hash; and the bar is pinned atheight: 3.5rem, which a padded three-line box overflows, so it becomesheight: auto; min-height: 3.5rem. The palette is hardcoded because those tokens live on.shadcn-scopeinside the React tree, and the picker is appended outside it.Rendering surfaces
useTxNotificationsanduseSafeMessageNotificationsrendereddetail.error.messageverbatim inside a<pre>; both now use the mapped sentence and withhold raw details for Ledger errors.ErrorMessageandTxSubmitErrorgained the same guard.How to test it
Manual, with a physical Ledger — each should show its sentence and no internals, including in the account picker:
Affected flows
ErrorMessage,TxSubmitError, or the tx/message notification toastsBlast radius
services/onboard/ledger-errors.ts,onboard/types.tsservices/onboard/ledger-module.tsmapEthersErrorand theLedgerErrorCodeconst enum removed; the single call site inwaitForActiondelegates tomapLedgerErrorservices/onboard/accountSelectAlert.tsservices/exceptions/index.tsCodedExceptionundefinedand no tag is emittedcomponents/tx/ErrorMessagecomponents/tx/TxSubmitErrorhooks/useTxNotifications,hooks/messages/useSafeMessageNotificationsdetailedMessagewithheld for Ledger errors onlypackages/utilserror taxonomy,trackErrorSurfacednormalizeError, the taxonomy, or the Mixpanel payloadNot touched: Trezor, mobile, RTK Query endpoints, feature flags, chain configs, persisted state, routes.
Risks / not checked
makeErrorleak reached this branch in the first place, since that path had no coverage.ledger-module.ts→mapLedgerErrordelegation has no automated test.waitForActionwas never exported; driving it needs a full DMK mock. It is one call site covered by type-check; the mapping behind it is fully tested.mode: 'open'and on.error-msgremaining the class@web3-onboard/hw-commonuses. Both are outside our control; a package upgrade could silently drop the skin (the message would still be correct, just unstyled).Deliberately out of scope, worth follow-up tickets:
services/onboard/trezor/errors.tshas the identicalpayload.error ?? 'unknown'pattern.useNotificationsRenewal.ts:117anduseInitSafeCoreSDK.ts:55also pass rawerr.messageintodetailedMessage(not on the HW signing path).isWCRejectionmatches a bare/rejected/on the message — a fragile global heuristic.Visual summary
flowchart TB D["Ledger device fails<br/>InvalidStatusWordError { _tag, originalError }"] subgraph Before D1["mapEthersError"] --> B1["error.message ?? 'unknown'<br/>info: raw error object"] B1 --> B2["ethers makeError"] --> B3["viem UnknownRpcError"] B3 --> B4["toast: 'unknown (info={...}, code=UNKNOWN_ERROR,<br/>version=6.17.0) Version: viem@2.52.2'"] B3 --> B5["analytics: type = unknown"] end subgraph After A1["readLedgerDeviceError"] --> A2["reads _tag + originalError.message<br/>+ nested errorCode"] A2 --> A3{"resolveReason"} A3 -->|"rejected"| A4["'You rejected the request on your Ledger.'<br/>ACTION_REJECTED — still matches matchUserOutcome"] A3 -->|"locked / app_closed /<br/>blind_signing / connection"| A5["mapped sentence"] A3 -->|"unmapped"| A6["fallback sentence<br/>+ LEDGER-0x<sw> reference"] A4 --> A7["buildLedgerError:<br/>the sentence IS message<br/>payload stays on info"] A5 --> A7 A6 --> A7 A7 --> A8["every renderer, ours and<br/>the account picker: sentence only"] A7 --> A9["CodedException reads the cause chain<br/>→ Datadog tags: ledger_reason, ledger_tag,<br/>ledger_status_word, ledger_device_message"] A7 --> A10["analytics: type = ledger_error<br/>enums only, no raw message"] end D --> D1 D --> A1Checklist