Skip to content

[WA-3243] Hardware-wallet errors reach users as "unknown": preserve and translate Ledger device errors (web) - #8568

Open
Clóvis Neto (clovisdasilvaneto) wants to merge 6 commits into
devfrom
fix/WA-3243-preserve-ledger-device-errors
Open

[WA-3243] Hardware-wallet errors reach users as "unknown": preserve and translate Ledger device errors (web)#8568
Clóvis Neto (clovisdasilvaneto) wants to merge 6 commits into
devfrom
fix/WA-3243-preserve-ledger-device-errors

Conversation

@clovisdasilvaneto

@clovisdasilvaneto Clóvis Neto (clovisdasilvaneto) commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What it solves

Resolves: WA-3243

Signing with a Ledger on web could fail with this toast:

Error submitting the transaction. Please try again.
Details: An unknown RPC error occurred. Details: unknown (info={ "_tag": "InvalidStatusWordError", "originalError": { } }, code=UNKNOWN_ERROR, version=6.17.0) Version: viem@2.52.2

Two defects in that one string:

  1. The device's own explanation was destroyed. The Ledger reports why it refused — locked, Ethereum app not open, blind signing off, USB dropped — and we dropped it before it reached either the screen or telemetry.
  2. Internals were printed to the user: an error class name, two library versions, and a serialised object.

Root cause was mapEthersError in ledger-module.ts:

return makeError(error.message ?? 'unknown', 'UNKNOWN_ERROR', { info: error })

InvalidStatusWordError has no message — its text lives in originalError, an Error, which serialises to {}. So we emitted the literal string unknown. It also carries no errorCode, so the 6985ACTION_REJECTED branch could never match it, and genuine user cancellations were reported as failures.

How this PR fixes it

services/onboard/ledger-errors.ts reads what the device actually said — _tag, the message from message or the nested originalError.message, and the status word from errorCode or the nested originalError.errorCode (UnknownDeviceExchangeError nests it). Status words and tags are taken from the shipped SDKs, not guessed: @ledgerhq/device-management-kit global errors and the transport family, @ledgerhq/device-signer-kit-ethereum app errors, OpenAppCommand. Each of the nine transport tags was read off the runtime class, which caught one export-name/_tag mismatch (OpeningConnectionError declares _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 as ACTION_REJECTED regardless of which DMK class carries it, so cancellations stop being counted as failures.

The error is built by hand, not by makeError

ethers' makeError appends every info key plus code= and version= to message, keeping the clean sentence only in shortMessage. message is what actually gets rendered — and one renderer is outside our reach: @web3-onboard/hw-common's account picker prints it verbatim. Going through makeError therefore delivered

Unlock your Ledger and try again. (info={ "deviceMessage": "Device locked.", … }, code=UNKNOWN_ERROR, version=6.17.0)

buildLedgerError constructs the error directly instead: 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 the payload previously reached Datadog only by being baked into the message. CodedException now 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: 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 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 with mode: 'open', which is the seam — services/onboard/accountSelectAlert.ts appends one stylesheet that re-skins .error-msg in place as the app's destructive Alert, with the same lucide circle-alert painted 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 at height: 3.5rem, which a padded three-line box overflows, so it becomes height: auto; min-height: 3.5rem. The palette is hardcoded because those tokens live on .shadcn-scope inside the React tree, and the picker is appended outside it.

Rendering surfaces

useTxNotifications and useSafeMessageNotifications rendered detail.error.message verbatim inside a <pre>; both now use the mapped sentence and withhold raw details for Ledger errors. ErrorMessage and TxSubmitError gained the same guard.

How to test it

yarn workspace @safe-global/web test --testPathPattern "(services/onboard|services/exceptions|TxSubmitError|ErrorMessage|useTxNotifications|useSafeMessageNotifications)"

Manual, with a physical Ledger — each should show its sentence and no internals, including in the account picker:

  1. Lock the device mid-signature → "Unlock your Ledger and try again."
  2. Close the Ethereum app and sign → "Open the Ethereum app on your Ledger."
  3. Turn blind signing off, sign a contract interaction → "Enable blind signing in the Ethereum app on your Ledger, then try again."
  4. Unplug mid-signature → "Lost connection to your Ledger. Reconnect it and try again."
  5. Reject on the device → the flow cancels quietly, and no error is recorded in analytics.
  6. Fail a scan in the account picker → the message renders as a destructive Alert, not bare red text, and the control bar grows rather than clipping.

Affected flows

  • Signing a transaction with a Ledger on web (execution and proposal)
  • Signing an off-chain message with a Ledger on web
  • The hardware-wallet account picker's scan-failure state
  • Any surface that renders a Ledger failure through ErrorMessage, TxSubmitError, or the tx/message notification toasts

Blast radius

Surface Consumers Change
services/onboard/ledger-errors.ts, onboard/types.ts new The mapping and its types
services/onboard/ledger-module.ts Ledger wallet module only mapEthersError and the LedgerErrorCode const enum removed; the single call site in waitForAction delegates to mapLedgerError
services/onboard/accountSelectAlert.ts new Shadow-root stylesheet for the picker; no React tree involvement
services/exceptions/index.ts broad — every CodedException Reads the Ledger payload off the cause chain and adds four tags. Non-Ledger errors are unaffected: the reader returns undefined and no tag is emitted
components/tx/ErrorMessage broad — many tx surfaces New branch ahead of the existing GS-code / custom-revert branches; non-Ledger errors take the identical path they did before
components/tx/TxSubmitError tx submission surfaces New first branch for Ledger; stale-nonce and rate-limit branches unchanged
hooks/useTxNotifications, hooks/messages/useSafeMessageNotifications global toasts detailedMessage withheld for Ledger errors only
packages/utils error taxonomy, trackErrorSurfaced web + mobile Read-only. No change to normalizeError, the taxonomy, or the Mixpanel payload

Not touched: Trezor, mobile, RTK Query endpoints, feature flags, chain configs, persisted state, routes.

Risks / not checked

  • No physical hardware was exercised. Every wrapping layer is reproduced from the shipped source, but the real DMK → viem → protocol-kit chain was not run against a device. This is the main thing to verify before merge — and it is how the makeError leak reached this branch in the first place, since that path had no coverage.
  • ledger-module.tsmapLedgerError delegation has no automated test. waitForAction was 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.
  • The account picker skin depends on a third-party shadow root staying mode: 'open' and on .error-msg remaining the class @web3-onboard/hw-common uses. Both are outside our control; a package upgrade could silently drop the skin (the message would still be correct, just unstyled).
  • Not tested on mobile — web-only by design; the mobile equivalent is WA-2287.
  • Status-word semantics are read from the SDK, not from a device. If a code is misassigned, a state could show the wrong sentence.

Deliberately out of scope, worth follow-up tickets:

  • services/onboard/trezor/errors.ts has the identical payload.error ?? 'unknown' pattern.
  • useNotificationsRenewal.ts:117 and useInitSafeCoreSDK.ts:55 also pass raw err.message into detailedMessage (not on the HW signing path).
  • isWCRejection matches 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&lt;sw&gt; 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 --> A1
Loading

Checklist

  • I've tested the branch on mobile 📱 — web-only change; mobile equivalent is WA-2287
  • I've documented how it affects the analytics (if at all) 📊
  • I've written a unit/e2e test for it (if applicable) 🧑‍💻
  • I've listed affected flows and blast radius, and named what I did not verify 🎯

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
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

📦 Next.js Bundle Analysis for @safe-global/web

This analysis was generated by the Next.js Bundle Analysis action. 🤖

⚠️ Global Bundle Size Increased

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.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Coverage report for apps/web

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
Clóvis Neto (clovisdasilvaneto) pushed a commit that referenced this pull request Aug 24, 2026
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

Copy link
Copy Markdown
Contributor Author

test check is red for a reason inherited from dev

Not caused by this PR — leaving it alone deliberately, so no one re-diagnoses it.

Failing test: CI build env consistency › passes every NEXT_PUBLIC_* var read in code to the CI build, unless allowlisted in the manifest (1 failed / 7637). Every other check on this PR is green, including build, lint, type-check, prettier, verify-changed, knip-exports, storybook-snapshots and all five Cypress Smoke shards.

Cause: NEXT_PUBLIC_SAFENET_EXPLORER_URL is read at packages/utils/src/features/safenet-checks/constants.ts:54 (introduced in #8485, 2026-08-18) but is absent from apps/web/scripts/github/ci-env-manifest.json and from every workflow env: block. The consistency check that catches it was last changed by #8533 — which is this branch's base commit 165157c.

Evidence it is not this PR's:

  • git diff --name-only 165157c..HEAD -- apps/web/scripts/github/ .github/ packages/utils/src/features/safenet-checks/ is empty — every input to that test is byte-identical to the base commit.
  • This diff contains no reference to NEXT_PUBLIC_SAFENET_EXPLORER_URL, no env var, no workflow, no manifest change.
  • Reproduced locally on this branch: 1 failed, 6 passed in that suite, same assertion.

Why it isn't fixed here: the fix is a CI-config change (registering the var in the manifest or the workflow env blocks), which is out of scope for WA-3243 and would widen this PR. It also affects every PR currently open against dev, not just this one, so it belongs in its own change.

This PR should go green on its own once dev is fixed; at that point the base gets merged in and CI re-runs.


Generated by Claude Code

@clovisdasilvaneto
Clóvis Neto (clovisdasilvaneto) marked this pull request as ready for review August 25, 2026 07:20
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants