diff --git a/apps/web/src/components/tx/ErrorMessage/__tests__/ErrorMessage.test.tsx b/apps/web/src/components/tx/ErrorMessage/__tests__/ErrorMessage.test.tsx
index d361260e0d..c4bde5c099 100644
--- a/apps/web/src/components/tx/ErrorMessage/__tests__/ErrorMessage.test.tsx
+++ b/apps/web/src/components/tx/ErrorMessage/__tests__/ErrorMessage.test.tsx
@@ -1,4 +1,6 @@
import { render, fireEvent } from '@/tests/test-utils'
+import type { DmkError } from '@ledgerhq/device-management-kit'
+import { mapLedgerError } from '@/services/onboard/ledger-errors'
import ErrorMessage from '..'
describe('ErrorMessage', () => {
@@ -34,6 +36,38 @@ describe('ErrorMessage', () => {
expect(queryByText(/rpc\.example\.org/)).toBeInTheDocument()
})
+ it('withholds the raw message of a Ledger device failure, mapped or not', () => {
+ const cause = mapLedgerError({
+ _tag: 'DeviceLockedError',
+ errorCode: '5515',
+ message: 'Device is locked.',
+ } as DmkError)
+ const error = Object.assign(new Error(`An unknown RPC error occurred. Details: ${cause.message}`), { cause })
+
+ const { getByText, queryByText, container } = render(
+ Unlock your Ledger and try again.,
+ )
+
+ expect(getByText('Unlock your Ledger and try again.')).toBeInTheDocument()
+ // A mapped state needs no support reference, and never the raw payload
+ expect(queryByText('Details')).not.toBeInTheDocument()
+ expect(container.textContent).not.toContain('DeviceLockedError')
+ expect(container.textContent).not.toContain('UNKNOWN_ERROR')
+ })
+
+ it('shows a support reference for a Ledger state we have no sentence for', () => {
+ const cause = mapLedgerError({ _tag: 'InvalidStatusWordError', originalError: new Error('V is missing') })
+ const error = Object.assign(new Error('An unknown RPC error occurred.'), { cause })
+
+ const { getByText, queryByText, container } = render(
+ Your Ledger could not complete the request.,
+ )
+
+ expect(getByText('LEDGER-UNKNOWN')).toBeInTheDocument()
+ expect(queryByText('Details')).not.toBeInTheDocument()
+ expect(container.textContent).not.toContain('V is missing')
+ })
+
it('treats a custom-error revert as GS013 and decodes a known selector', () => {
const error = Object.assign(new Error('execution reverted (unknown custom error)'), {
code: 'CALL_EXCEPTION',
diff --git a/apps/web/src/components/tx/ErrorMessage/index.tsx b/apps/web/src/components/tx/ErrorMessage/index.tsx
index 5335651107..d565d72007 100644
--- a/apps/web/src/components/tx/ErrorMessage/index.tsx
+++ b/apps/web/src/components/tx/ErrorMessage/index.tsx
@@ -7,6 +7,7 @@ import useSafeInfo from '@/hooks/useSafeInfo'
import { useCurrentChain } from '@/hooks/useChains'
import ExternalLink from '@/components/common/ExternalLink'
import ErrorDetails from '@/components/common/ErrorDetails'
+import { getLedgerDeviceError, getLedgerSupportReference } from '@/services/onboard/ledger-errors'
import { Alert, AlertDescription, AlertTitle, AlertSeverityIcon } from '@/components/ui/alert'
import { Typography } from '@/components/ui/typography'
import { Link } from '@/components/ui/link'
@@ -44,6 +45,14 @@ const ErrorMessage = ({
// before (WA-3005 is on-chain-scoped).
const gsCode = error ? getGsCodeFromError(error) : undefined
+ // A Ledger device failure carries its own translated sentence, so the raw
+ // message must never be offered: by the time it reaches us it has been
+ // re-wrapped by ethers and viem and reads as a dump of class names, codes and
+ // library versions (WA-3243). An unmapped device state gets a support
+ // reference instead — the device's own words stay in telemetry.
+ const ledgerError = error ? getLedgerDeviceError(error) : undefined
+ const ledgerReference = ledgerError?.reason === 'unknown' ? getLedgerSupportReference(ledgerError) : undefined
+
// GS013 family: the inner call reverted with a module/guard custom error. A
// custom-error revert without a GS string is still a GS013 — decode its
// selector against the known ABIs; undecodable ones keep the raw selector in
@@ -92,7 +101,7 @@ const ErrorMessage = ({
)}
- {error && !effectiveGsCode && (
+ {error && !effectiveGsCode && !ledgerError && (
}
onClick={onDetailsToggle}
@@ -105,6 +114,8 @@ const ErrorMessage = ({
{effectiveGsCode ? (
+ ) : ledgerError ? (
+ ledgerReference &&
) : (
error &&
showDetails && (
diff --git a/apps/web/src/components/tx/TxSubmitError/__tests__/TxSubmitError.test.tsx b/apps/web/src/components/tx/TxSubmitError/__tests__/TxSubmitError.test.tsx
index bff973005c..0d53ea0314 100644
--- a/apps/web/src/components/tx/TxSubmitError/__tests__/TxSubmitError.test.tsx
+++ b/apps/web/src/components/tx/TxSubmitError/__tests__/TxSubmitError.test.tsx
@@ -2,8 +2,19 @@ import { render } from '@/tests/test-utils'
import type { EthersError } from '@/utils/ethers-utils'
import type { TransactionReceipt } from 'ethers'
import { Gs026PreCheckError } from '@/services/tx/executionPreChecks'
+import type { DmkError } from '@ledgerhq/device-management-kit'
+import { mapLedgerError } from '@/services/onboard/ledger-errors'
import TxSubmitError from '..'
+/** The ethers error the Ledger module rejects with, as viem re-wraps it. */
+const ledgerSubmitError = (error: Parameters[0]): Error => {
+ const cause = mapLedgerError(error)
+ return Object.assign(
+ new Error(`An unknown RPC error occurred.\n\nDetails: ${cause.message}\n\nVersion: viem@2.52.2`),
+ { cause },
+ )
+}
+
describe('TxSubmitError', () => {
it('shows the cause-specific message for a failed GS026 pre-check', () => {
const { getByText, queryByText } = render()
@@ -59,6 +70,37 @@ describe('TxSubmitError', () => {
expect(queryByText(/try again/i)).not.toBeInTheDocument()
})
+ it('shows what the Ledger asked for instead of the generic submit failure', () => {
+ const error = ledgerSubmitError({
+ _tag: 'DeviceLockedError',
+ errorCode: '5515',
+ message: 'Device is locked.',
+ } as DmkError)
+
+ const { getByText, queryByText } = render()
+
+ expect(getByText('Unlock your Ledger and try again.')).toBeInTheDocument()
+ expect(queryByText(/Could not submit/)).not.toBeInTheDocument()
+ })
+
+ it('never leaks device or library internals for a Ledger failure', () => {
+ // The shape from the bug report: no message, the reason wrapped in an Error.
+ const error = ledgerSubmitError({
+ _tag: 'InvalidStatusWordError',
+ originalError: new Error('no signature returned'),
+ })
+
+ const { getByText, queryByText, container } = render()
+
+ expect(getByText('Your Ledger could not complete the request.')).toBeInTheDocument()
+ expect(getByText('LEDGER-UNKNOWN')).toBeInTheDocument()
+ expect(queryByText('Details')).not.toBeInTheDocument()
+
+ for (const forbidden of ['viem@', 'version=', 'InvalidStatusWordError', 'UNKNOWN_ERROR', 'info={']) {
+ expect(container.textContent).not.toContain(forbidden)
+ }
+ })
+
it('offers a retry for a transient (non-revert) submission failure', () => {
const error = new Error('HTTP request failed. Status: 500')
diff --git a/apps/web/src/components/tx/TxSubmitError/index.tsx b/apps/web/src/components/tx/TxSubmitError/index.tsx
index 3c775fea84..5b29f701a7 100644
--- a/apps/web/src/components/tx/TxSubmitError/index.tsx
+++ b/apps/web/src/components/tx/TxSubmitError/index.tsx
@@ -10,6 +10,7 @@ import {
import { didRevert, type EthersError } from '@/utils/ethers-utils'
import { isGs026PreCheckError } from '@/services/tx/executionPreChecks'
import ErrorMessage from '@/components/tx/ErrorMessage'
+import { getLedgerDeviceError, getLedgerUserMessage } from '@/services/onboard/ledger-errors'
export const COULD_NOT_SUBMIT_MESSAGE = 'Could not submit the transaction.'
export const COULD_NOT_SUBMIT_RETRY_MESSAGE = 'Could not submit the transaction. Try again.'
@@ -35,6 +36,18 @@ const TxSubmitError = ({
}): ReactElement => {
const chain = useCurrentChain()
+ // The Ledger refused before anything was broadcast, and it said why. Its own
+ // reason beats every generic classification below — matching on the wrapped
+ // message would only rediscover viem's "unknown RPC error" (WA-3243).
+ const ledgerError = getLedgerDeviceError(error)
+ if (ledgerError) {
+ return (
+
+ {getLedgerUserMessage(ledgerError)}
+
+ )
+ }
+
// A failed GS026 pre-check blocked the broadcast — show its specific,
// cause-aware message (stale nonce / not a signer / bad signature).
if (isGs026PreCheckError(error)) {
diff --git a/apps/web/src/hooks/__tests__/useTxNotifications.test.ts b/apps/web/src/hooks/__tests__/useTxNotifications.test.ts
new file mode 100644
index 0000000000..bd15b67ddd
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/useTxNotifications.test.ts
@@ -0,0 +1,145 @@
+import type { DmkError } from '@ledgerhq/device-management-kit'
+
+import { renderHook, waitFor } from '@/tests/test-utils'
+import { chainBuilder } from '@/tests/builders/chains'
+import { showNotification } from '@/store/notificationsSlice'
+import { txDispatch, TxEvent } from '@/services/tx/txEvents'
+import { mapLedgerError } from '@/services/onboard/ledger-errors'
+import useTxNotifications from '../useTxNotifications'
+
+jest.mock('@/store/notificationsSlice', () => {
+ const original = jest.requireActual('@/store/notificationsSlice')
+ return {
+ ...original,
+ showNotification: jest.fn(original.showNotification),
+ }
+})
+
+const MOCK_CHAIN = chainBuilder().with({ chainId: '1', chainName: 'Ethereum' }).build()
+
+jest.mock('@/hooks/useChains', () => ({
+ __esModule: true,
+ useCurrentChain: jest.fn(() => MOCK_CHAIN),
+}))
+
+jest.mock('@/hooks/useTxQueue', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({ page: undefined })),
+}))
+
+jest.mock('@/hooks/useIsSafeOwner', () => ({
+ __esModule: true,
+ default: jest.fn(() => false),
+}))
+
+jest.mock('@/hooks/wallets/useWallet', () => ({
+ __esModule: true,
+ default: jest.fn(() => null),
+}))
+
+jest.mock('@safe-global/store/gateway/AUTO_GENERATED/transactions', () => ({
+ __esModule: true,
+ useLazyTransactionsGetTransactionByIdV1Query: jest.fn(() => [jest.fn(() => Promise.resolve({ data: undefined }))]),
+}))
+
+/** The ethers error the Ledger module rejects with, as viem re-wraps it. */
+const ledgerSignError = (error: DmkError): Error => {
+ const cause = mapLedgerError(error)
+ return Object.assign(
+ new Error(`An unknown RPC error occurred.\n\nDetails: ${cause.message}\n\nVersion: viem@2.52.2`),
+ { cause },
+ )
+}
+
+const lastNotification = () => {
+ const calls = (showNotification as unknown as jest.Mock).mock.calls
+ return calls[calls.length - 1]?.[0]
+}
+
+describe('useTxNotifications', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('shows the generic failure message, with raw details, for an ordinary error', () => {
+ renderHook(() => useTxNotifications())
+
+ txDispatch(TxEvent.SIGN_FAILED, { error: new Error('HTTP request failed. Status: 500') })
+
+ expect(lastNotification()).toMatchObject({
+ message: 'Failed to sign. Please try again. ',
+ detailedMessage: 'HTTP request failed. Status: 500',
+ variant: 'error',
+ })
+ })
+
+ it('translates a Ledger device failure into what the device asked for', () => {
+ renderHook(() => useTxNotifications())
+
+ txDispatch(TxEvent.SIGN_FAILED, {
+ error: ledgerSignError({
+ _tag: 'DeviceLockedError',
+ errorCode: '5515',
+ message: 'Device is locked.',
+ } as DmkError),
+ })
+
+ expect(lastNotification()).toMatchObject({
+ message: 'Unlock your Ledger and try again.',
+ variant: 'error',
+ })
+ })
+
+ it('withholds the raw payload of a Ledger failure from the Details pane', () => {
+ renderHook(() => useTxNotifications())
+
+ // The shape from the bug report: no message, the reason wrapped in an Error.
+ txDispatch(TxEvent.SIGN_FAILED, {
+ error: ledgerSignError({ _tag: 'InvalidStatusWordError', originalError: new Error('no signature returned') }),
+ })
+
+ const notification = lastNotification()
+
+ expect(notification.message).toBe('Your Ledger could not complete the request.')
+ // `detailedMessage` is rendered verbatim in a
— this is the surface
+ // that leaked "code=UNKNOWN_ERROR ... Version: viem@2.52.2" (WA-3243).
+ expect(notification.detailedMessage).toBeUndefined()
+
+ for (const forbidden of ['viem@', 'version=', 'InvalidStatusWordError', 'UNKNOWN_ERROR', 'info={']) {
+ expect(JSON.stringify(notification)).not.toContain(forbidden)
+ }
+ })
+
+ it('stays silent when the user cancels on the device', () => {
+ renderHook(() => useTxNotifications())
+
+ txDispatch(TxEvent.SIGN_FAILED, {
+ error: ledgerSignError({
+ _tag: 'EthAppCommandError',
+ errorCode: '6985',
+ message: 'Condition not satisfied',
+ } as DmkError),
+ })
+
+ expect(showNotification).not.toHaveBeenCalled()
+ })
+
+ it('keeps the mined-revert message ahead of every other classification', async () => {
+ renderHook(() => useTxNotifications())
+
+ txDispatch(TxEvent.REVERTED, {
+ txId: '0x1',
+ groupKey: '0x1',
+ chainId: '1',
+ safeAddress: '0x0000000000000000000000000000000000000001',
+ error: new Error('reverted'),
+ })
+
+ // A notification carrying a txId resolves the human description first.
+ await waitFor(() =>
+ expect(lastNotification()).toMatchObject({
+ message: 'Transaction reverted on Ethereum. Gas was spent.',
+ }),
+ )
+ })
+})
diff --git a/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts b/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts
index e819e3e725..0363df3b8b 100644
--- a/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts
+++ b/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts
@@ -6,6 +6,7 @@ import { showNotification } from '@/store/notificationsSlice'
import { renderHook } from '@/tests/test-utils'
import useSafeMessageNotifications, { _getSafeMessagesAwaitingConfirmations } from '../useSafeMessageNotifications'
import type { PendingSafeMessagesState } from '@/store/pendingSafeMessagesSlice'
+import { mapLedgerError } from '@/services/onboard/ledger-errors'
jest.mock('@/store/notificationsSlice', () => {
const original = jest.requireActual('@/store/notificationsSlice')
@@ -157,6 +158,25 @@ describe('useSafeMessageNotifications', () => {
})
})
+ it('should translate a Ledger device failure and withhold its raw details', () => {
+ renderHook(() => useSafeMessageNotifications())
+
+ const cause = mapLedgerError({ _tag: 'InvalidStatusWordError', originalError: new Error('no signature returned') })
+ const error = Object.assign(
+ new Error(`An unknown RPC error occurred.\n\nDetails: ${cause.message}\n\nVersion: viem@2.52.2`),
+ { cause },
+ )
+
+ safeMsgDispatch(SafeMsgEvent.CONFIRM_PROPOSE_FAILED, { messageHash: '0x789', error })
+
+ expect(showNotification).toHaveBeenCalledWith({
+ message: 'Your Ledger could not complete the request.',
+ detailedMessage: undefined,
+ groupKey: '0x789',
+ variant: 'error',
+ })
+ })
+
it('should show a notification when a message fully is confirmed', () => {
renderHook(() => useSafeMessageNotifications())
diff --git a/apps/web/src/hooks/messages/useSafeMessageNotifications.ts b/apps/web/src/hooks/messages/useSafeMessageNotifications.ts
index 67d2c0bba4..d9c1d400e9 100644
--- a/apps/web/src/hooks/messages/useSafeMessageNotifications.ts
+++ b/apps/web/src/hooks/messages/useSafeMessageNotifications.ts
@@ -15,6 +15,7 @@ import { useCurrentChain } from '@/hooks/useChains'
import useSafeAddress from '@/hooks/useSafeAddress'
import type { PendingSafeMessagesState } from '@/store/pendingSafeMessagesSlice'
import { isWalletRejection } from '@/utils/wallets'
+import { getLedgerDeviceError, getLedgerUserMessage } from '@/services/onboard/ledger-errors'
const SafeMessageNotifications: Partial> = {
[SafeMsgEvent.PROPOSE]: 'You successfully signed the message.',
@@ -52,12 +53,19 @@ const useSafeMessageNotifications = () => {
const isError = 'error' in detail
if (isError && isWalletRejection(detail.error)) return
const isSuccess = event === SafeMsgEvent.PROPOSE || event === SafeMsgEvent.SIGNATURE_PREPARED
- const message = isError ? `${baseMessage}${formatError(detail.error)}` : baseMessage
+ // A Ledger device failure states its own reason; its raw error is a
+ // dump of DMK class names, ethers codes and the viem version (WA-3243).
+ const ledgerError = isError ? getLedgerDeviceError(detail.error) : undefined
+ const message = ledgerError
+ ? getLedgerUserMessage(ledgerError)
+ : isError
+ ? `${baseMessage}${formatError(detail.error)}`
+ : baseMessage
dispatch(
showNotification({
message,
- detailedMessage: isError ? detail.error.message : undefined,
+ detailedMessage: isError && !ledgerError ? detail.error.message : undefined,
groupKey: detail.messageHash,
variant: isError ? 'error' : isSuccess ? 'success' : 'info',
}),
diff --git a/apps/web/src/hooks/useTxNotifications.ts b/apps/web/src/hooks/useTxNotifications.ts
index dbfdb6b948..04ce085dd9 100644
--- a/apps/web/src/hooks/useTxNotifications.ts
+++ b/apps/web/src/hooks/useTxNotifications.ts
@@ -22,6 +22,7 @@ import {
RATE_LIMIT_USER_MESSAGE,
} from '@/utils/transaction-errors'
import { getGs026Message } from '@safe-global/utils/services/exceptions/contractErrors'
+import { getLedgerDeviceError, getLedgerUserMessage } from '@/services/onboard/ledger-errors'
const TxNotifications = {
[TxEvent.SIGN_FAILED]: 'Failed to sign. Please try again.',
@@ -72,6 +73,10 @@ const useTxNotifications = (): void => {
// Check if this is a Guard error
const guardErrorName = isError ? getGuardErrorInfo(detail.error) : undefined
+ // A Ledger device failure states its own reason. Its raw error is a
+ // dump of DMK class names, ethers codes and the viem version, so it is
+ // withheld from `detailedMessage` too (WA-3243).
+ const ledgerError = isError ? getLedgerDeviceError(detail.error) : undefined
let message = isError ? `${baseMessage} ${formatError(detail.error)}` : baseMessage
// Override message for Guard errors
@@ -85,6 +90,8 @@ const useTxNotifications = (): void => {
// RPC rejected it pre-mining (no gas spent). Same user story as a
// stale Safe nonce, so show the same message.
message = getGs026Message('STALE_NONCE')
+ } else if (ledgerError) {
+ message = getLedgerUserMessage(ledgerError)
} else if (isError && isRateLimitError(detail.error)) {
// Translate transient RPC rate-limit failures into friendly copy.
// The raw error from viem looks like a contract revert ("Request is
@@ -110,7 +117,7 @@ const useTxNotifications = (): void => {
showNotification({
title: humanDescription,
message,
- detailedMessage: isError ? detail.error.message : undefined,
+ detailedMessage: isError && !ledgerError ? detail.error.message : undefined,
groupKey,
variant: isError ? Variant.ERROR : isSuccess ? Variant.SUCCESS : Variant.INFO,
link: txId
diff --git a/apps/web/src/services/exceptions/__tests__/index.test.ts b/apps/web/src/services/exceptions/__tests__/index.test.ts
index 0b601c2a3f..edb435f7c3 100644
--- a/apps/web/src/services/exceptions/__tests__/index.test.ts
+++ b/apps/web/src/services/exceptions/__tests__/index.test.ts
@@ -193,6 +193,71 @@ describe('CodedException', () => {
)
})
+ it('tags the Datadog error with what the Ledger actually said (WA-3243)', async () => {
+ process.env.NEXT_PUBLIC_IS_PRODUCTION = 'true'
+ const mockCaptureError = jest.fn()
+ mockObservability(mockCaptureError)
+
+ const { trackError, Errors } = await import('..')
+ const { mapLedgerError } = await import('@/services/onboard/ledger-errors')
+
+ // As the UI sees it: viem re-wraps the device error before it is tracked.
+ const deviceError = mapLedgerError({
+ _tag: 'InvalidStatusWordError',
+ originalError: new Error('no signature returned'),
+ })
+ const wrapped = Object.assign(
+ new Error(`An unknown RPC error occurred.\n\nDetails: ${deviceError.message}\n\nVersion: viem@2.52.2`),
+ { cause: deviceError },
+ )
+
+ const err = trackError(Errors._804, wrapped)
+
+ // The device's own words are the point of the tags: they are deliberately
+ // absent from the message, which is the sentence the user reads.
+ expect(err.message).not.toContain('no signature returned')
+ expect(mockCaptureError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tags: expect.objectContaining({
+ error_type: ErrorType.LEDGER_ERROR,
+ ledger_reason: 'unknown',
+ ledger_tag: 'InvalidStatusWordError',
+ ledger_device_message: 'no signature returned',
+ }),
+ }),
+ )
+ })
+
+ it('tags the status word a device exchange error reports', async () => {
+ process.env.NEXT_PUBLIC_IS_PRODUCTION = 'true'
+ const mockCaptureError = jest.fn()
+ mockObservability(mockCaptureError)
+
+ const { trackError, Errors } = await import('..')
+ const { mapLedgerError } = await import('@/services/onboard/ledger-errors')
+
+ trackError(Errors._804, mapLedgerError({ _tag: 'DeviceLockedError', errorCode: '5515' } as never))
+
+ expect(mockCaptureError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tags: expect.objectContaining({ ledger_reason: 'locked', ledger_status_word: '5515' }),
+ }),
+ )
+ })
+
+ it('leaves the Ledger tags off an error no device raised', async () => {
+ process.env.NEXT_PUBLIC_IS_PRODUCTION = 'true'
+ const mockCaptureError = jest.fn()
+ mockObservability(mockCaptureError)
+
+ const { trackError, Errors } = await import('..')
+
+ trackError(Errors._804, new Error('execution reverted'))
+
+ const [{ tags }] = mockCaptureError.mock.calls[0]
+ expect(Object.keys(tags).some((key) => key.startsWith('ledger_'))).toBe(false)
+ })
+
it('merges RPC endpoint context into the Datadog tags', async () => {
process.env.NEXT_PUBLIC_IS_PRODUCTION = 'true'
const mockCaptureError = jest.fn()
diff --git a/apps/web/src/services/exceptions/index.ts b/apps/web/src/services/exceptions/index.ts
index 69e55977c0..ea0230a8f1 100644
--- a/apps/web/src/services/exceptions/index.ts
+++ b/apps/web/src/services/exceptions/index.ts
@@ -3,6 +3,8 @@ import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes'
import { asError, getHttpStatusFromError } from '@safe-global/utils/services/exceptions/utils'
import { normalizeError } from '@safe-global/utils/services/exceptions/normalizeError'
import { logger, captureError } from '../observability'
+import { getLedgerDeviceError } from '@/services/onboard/ledger-errors'
+import type { LedgerDeviceErrorInfo } from '@/services/onboard/types'
import type { ErrorContext } from '../observability/types'
// Re-exported for back-compat with `@/services/exceptions` call sites.
@@ -14,6 +16,13 @@ export class CodedException extends Error {
public readonly content: string
/** HTTP status of the wrapped request failure, when one is recoverable from `thrown`. */
public readonly httpStatus?: number
+ /**
+ * What the hardware wallet actually said, when the thrown error came from
+ * one. Read off the error rather than out of `message`: the sentence we show
+ * the user deliberately contains none of it (WA-3243), so this is the only
+ * route by which the tag, status word and device text still reach Datadog.
+ */
+ private readonly ledgerDevice?: LedgerDeviceErrorInfo
private getCode(content: ErrorCodes): number {
const codePrefix = content.split(':')[0]
@@ -32,6 +41,7 @@ export class CodedException extends Error {
this.code = this.getCode(content)
this.content = content
this.httpStatus = getHttpStatusFromError(thrown)
+ this.ledgerDevice = getLedgerDeviceError(thrown)
}
/**
@@ -66,6 +76,12 @@ export class CodedException extends Error {
...(context?.rpcEndpointKind && { rpc_endpoint_kind: context.rpcEndpointKind }),
...(context?.rpcHost && { rpc_host: context.rpcHost }),
...(context?.httpStatus && { http_status: context.httpStatus }),
+ ...(this.ledgerDevice && {
+ ledger_reason: this.ledgerDevice.reason,
+ ledger_tag: this.ledgerDevice.tag,
+ ...(this.ledgerDevice.errorCode && { ledger_status_word: this.ledgerDevice.errorCode }),
+ ...(this.ledgerDevice.deviceMessage && { ledger_device_message: this.ledgerDevice.deviceMessage }),
+ }),
}
}
diff --git a/apps/web/src/services/onboard/accountSelectAlert.test.ts b/apps/web/src/services/onboard/accountSelectAlert.test.ts
new file mode 100644
index 0000000000..f79c392c41
--- /dev/null
+++ b/apps/web/src/services/onboard/accountSelectAlert.test.ts
@@ -0,0 +1,120 @@
+import { styleAccountSelectAlert } from './accountSelectAlert'
+
+const STYLE_ID = 'safe-account-select-alert'
+
+/** The picker as `@web3-onboard/hw-common` mounts it: an open shadow root holding the control bar. */
+const mountPicker = (): ShadowRoot => {
+ const host = document.createElement('account-select')
+ const root = host.attachShadow({ mode: 'open' })
+ root.innerHTML = `
+
+
+ Unlock your Ledger and try again.
+
+
+ `
+ document.body.appendChild(host)
+ return root
+}
+
+const readStyle = (root: ShadowRoot): string => root.getElementById(STYLE_ID)?.textContent ?? ''
+
+describe('styleAccountSelectAlert', () => {
+ afterEach(() => {
+ document.body.innerHTML = ''
+ document.documentElement.removeAttribute('data-theme')
+ })
+
+ it('skins the scan error as the destructive Alert', () => {
+ const root = mountPicker()
+
+ styleAccountSelectAlert()
+
+ const css = readStyle(root)
+ expect(css).toContain('#fff4f6') // Alert `--error-subtle`
+ expect(css).toContain('#dc2626') // Alert `--destructive`, on the icon
+ expect(css).toContain('border-radius: 6px') // `rounded-md`
+ expect(css).toContain('font-size: 0.875rem') // `text-sm`
+ expect(css).toContain('mask:') // the severity icon, painted through a mask
+ })
+
+ it('outranks the widget’s own scoped rule without naming its build hash', () => {
+ const root = mountPicker()
+
+ styleAccountSelectAlert()
+
+ const css = readStyle(root)
+ // Svelte ships `.error-msg.svelte-`; the hash changes on every rebuild
+ // of the package, so the override has to win on repeated classes instead.
+ expect(css).toContain('.error-msg.error-msg.error-msg')
+ expect(css).not.toContain('svelte-')
+ })
+
+ it('leaves the element where the widget put it', () => {
+ const root = mountPicker()
+
+ styleAccountSelectAlert()
+
+ // Only the rule for the element itself matters here: the container rule
+ // before it lifts the bar's fixed height, and the `::before` block after it
+ // sizes the icon, so both legitimately carry layout properties.
+ const [, ...elementRuleParts] = readStyle(root).split('::before')[0].split('.error-msg')
+ const elementRule = elementRuleParts.join('.error-msg')
+ for (const layoutProperty of ['position:', 'order:', 'top:', 'left:', 'max-width:', 'width:']) {
+ expect(elementRule).not.toContain(layoutProperty)
+ }
+ expect(root.querySelector('.table-controls .error-msg')).not.toBeNull()
+ })
+
+ it('lets the control bar grow instead of clipping a taller alert', () => {
+ const root = mountPicker()
+
+ styleAccountSelectAlert()
+
+ // The widget pins the bar at `height: 3.5rem`; a padded alert on three lines
+ // is taller than that and would otherwise spill past the bar's edge.
+ const containerRule = readStyle(root).split('.error-msg')[0]
+ expect(containerRule).toContain('.table-controls')
+ expect(containerRule).toContain('height: auto')
+ expect(containerRule).toContain('min-height: 3.5rem')
+ })
+
+ it('uses the dark palette when the app is in dark mode', () => {
+ document.documentElement.setAttribute('data-theme', 'dark')
+ const root = mountPicker()
+
+ styleAccountSelectAlert()
+
+ const css = readStyle(root)
+ expect(css).toContain('#2f2527')
+ expect(css).toContain('#ff5f72')
+ expect(css).not.toContain('#fff4f6')
+ })
+
+ it('refreshes an already-styled picker rather than stacking stylesheets', () => {
+ const root = mountPicker()
+ styleAccountSelectAlert()
+
+ document.documentElement.setAttribute('data-theme', 'dark')
+ styleAccountSelectAlert()
+
+ expect(root.querySelectorAll(`#${STYLE_ID}`)).toHaveLength(1)
+ expect(readStyle(root)).toContain('#2f2527')
+ })
+
+ it('styles a freshly mounted picker even when the previous one is still in the DOM', () => {
+ // `accountSelect` destroys the Svelte app on close but leaves its host behind,
+ // so a second connect attempt adds another to the body.
+ const stale = mountPicker()
+ const fresh = mountPicker()
+
+ styleAccountSelectAlert()
+
+ expect(readStyle(stale)).not.toBe('')
+ expect(readStyle(fresh)).not.toBe('')
+ })
+
+ it('does nothing when no picker is mounted', () => {
+ expect(() => styleAccountSelectAlert()).not.toThrow()
+ })
+})
diff --git a/apps/web/src/services/onboard/accountSelectAlert.ts b/apps/web/src/services/onboard/accountSelectAlert.ts
new file mode 100644
index 0000000000..e78de51edd
--- /dev/null
+++ b/apps/web/src/services/onboard/accountSelectAlert.ts
@@ -0,0 +1,105 @@
+/**
+ * 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 an *open* shadow root, 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, severity icon (WA-3243).
+ * The element keeps its slot and its own positioning; only its skin changes.
+ */
+
+const STYLE_ID = 'safe-account-select-alert'
+
+/**
+ * `Alert` variant=destructive, outlined=false, as shadcn.css resolves it.
+ * Hardcoded because those tokens are defined on `.shadcn-scope`, which is
+ * applied to wrappers inside the React tree — the picker is appended to
+ * ``, outside every one of them, so it inherits none of them. Text colour
+ * still comes from a `:root` token, which does reach the shadow tree.
+ */
+const ALERT_PALETTE = {
+ light: { background: '#fff4f6', icon: '#dc2626' },
+ dark: { background: '#2f2527', icon: '#ff5f72' },
+} as const
+
+/** lucide `circle-alert` (the Alert's own destructive icon), as a mask so it takes the palette colour. */
+const ICON_MASK =
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 8v4'/%3E%3Cpath d='M12 16h.01'/%3E%3C/svg%3E\") center / contain no-repeat"
+
+/**
+ * Svelte scopes its own rule as `.error-msg.svelte-`, so a single class
+ * would lose on specificity. The class is repeated to outrank it without
+ * hardcoding the hash, which changes whenever they rebuild the package.
+ */
+const TARGET = '.error-msg.error-msg.error-msg'
+
+/**
+ * The bar holding the checkbox, the error and the Scan Accounts button. The
+ * widget pins it to a fixed `height: 3.5rem`, which a tinted, padded alert
+ * spills out of as soon as the sentence needs a third line. Letting it grow is
+ * the one concession the skin needs; nothing moves, the bar just gets taller.
+ */
+const CONTAINER = '.table-controls.table-controls.table-controls'
+
+const buildCss = ({ background, icon }: (typeof ALERT_PALETTE)[keyof typeof ALERT_PALETTE]): string => `
+${CONTAINER} {
+ height: auto;
+ min-height: 3.5rem;
+}
+${TARGET} {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ box-sizing: border-box;
+ padding: 0.5rem 0.75rem;
+ border-radius: 6px;
+ background: ${background};
+ color: var(--color-text-primary, inherit);
+ font-size: 0.875rem;
+ line-height: 1.25;
+ text-align: left;
+}
+${TARGET}::before {
+ content: '';
+ flex: none;
+ width: 1rem;
+ height: 1rem;
+ margin-top: 0.0625rem;
+ background-color: ${icon};
+ -webkit-mask: ${ICON_MASK};
+ mask: ${ICON_MASK};
+}
+`
+
+const isDarkMode = (): boolean => document.documentElement.getAttribute('data-theme') === 'dark'
+
+/**
+ * Skins the picker's scan error as an Alert.
+ *
+ * Safe to call on every open: `accountSelect` mounts a fresh ``
+ * each time and leaves the previous one behind, so every host is visited and
+ * an already-styled one is only refreshed (the theme may have changed since).
+ */
+export const styleAccountSelectAlert = (): void => {
+ if (typeof document === 'undefined') return
+
+ const css = buildCss(ALERT_PALETTE[isDarkMode() ? 'dark' : 'light'])
+
+ document.querySelectorAll('account-select').forEach((host) => {
+ const root = host.shadowRoot
+ if (!root) return
+
+ const existing = root.getElementById(STYLE_ID)
+ if (existing) {
+ existing.textContent = css
+ return
+ }
+
+ const style = document.createElement('style')
+ style.id = STYLE_ID
+ style.textContent = css
+ root.appendChild(style)
+ })
+}
diff --git a/apps/web/src/services/onboard/ledger-errors.test.ts b/apps/web/src/services/onboard/ledger-errors.test.ts
new file mode 100644
index 0000000000..53a2932643
--- /dev/null
+++ b/apps/web/src/services/onboard/ledger-errors.test.ts
@@ -0,0 +1,324 @@
+import type { DmkError } from '@ledgerhq/device-management-kit'
+import { ErrorType } from '@safe-global/utils/services/exceptions/errorTaxonomy'
+import { matchUserOutcome, normalizeError } from '@safe-global/utils/services/exceptions/normalizeError'
+import { isWalletRejection } from '@/utils/wallets'
+import type { EthersError } from '@/utils/ethers-utils'
+
+import {
+ getLedgerDeviceError,
+ getLedgerSupportReference,
+ getLedgerUserMessage,
+ mapLedgerError,
+ readLedgerDeviceError,
+} from './ledger-errors'
+
+type MappedError = EthersError & { shortMessage?: string }
+
+/**
+ * Substrings that must never reach a user-facing string: library names and
+ * versions, DMK/ethers class names and codes, and serialised payloads.
+ */
+const FORBIDDEN_IN_UI = ['viem@', 'version=', 'code=', 'info={', 'Error', '_tag', '{', '}']
+
+/** `InvalidStatusWordError`: no `message`, the reason hides in `originalError`. */
+const invalidStatusWord = (message?: string): DmkError => ({
+ _tag: 'InvalidStatusWordError',
+ ...(message ? { originalError: new Error(message) } : {}),
+})
+
+/** `DeviceExchangeError` subclasses (eth app, global handler) carry a status word. */
+const deviceExchangeError = (tag: string, errorCode: string, message: string): DmkError =>
+ ({ _tag: tag, errorCode, message }) as DmkError
+
+/** How the error looks by the time it is displayed: ethers → viem → protocol-kit. */
+const wrapLikeViem = (cause: Error): Error =>
+ Object.assign(new Error(`An unknown RPC error occurred.\n\nDetails: ${cause.message}\n\nVersion: viem@2.52.2`), {
+ cause,
+ })
+
+describe('ledger-errors', () => {
+ describe('readLedgerDeviceError', () => {
+ it('recovers the reason an InvalidStatusWordError hides in originalError', () => {
+ expect(readLedgerDeviceError(invalidStatusWord('no signature returned'))).toEqual({
+ source: 'ledger-device',
+ reason: 'unknown',
+ tag: 'InvalidStatusWordError',
+ errorCode: undefined,
+ deviceMessage: 'no signature returned',
+ })
+ })
+
+ it('never invents a reason when the device gave none', () => {
+ const info = readLedgerDeviceError(invalidStatusWord())
+
+ expect(info.deviceMessage).toBeUndefined()
+ expect(info.tag).toBe('InvalidStatusWordError')
+ expect(info.reason).toBe('unknown')
+ })
+
+ it('reads a status word nested in originalError (UnknownDeviceExchangeError)', () => {
+ const info = readLedgerDeviceError({
+ _tag: 'UnknownDeviceExchangeError',
+ originalError: { message: 'UnknownError', errorCode: '6511' },
+ message: 'Unexpected device exchange error happened.',
+ } as DmkError)
+
+ expect(info.errorCode).toBe('6511')
+ expect(info.reason).toBe('app_closed')
+ })
+
+ it.each([
+ { tag: 'EthAppCommandError', errorCode: '6985', message: 'Condition not satisfied', reason: 'rejected' },
+ { tag: 'EthAppCommandError', errorCode: '6982', message: 'Canceled by user', reason: 'rejected' },
+ { tag: 'DeviceLockedError', errorCode: '5515', message: 'Device is locked.', reason: 'locked' },
+ { tag: 'OpenAppCommandError', errorCode: '6807', message: 'Unknown application name', reason: 'app_closed' },
+ { tag: 'DeviceInternalError', errorCode: '6e00', message: 'CLA not supported', reason: 'app_closed' },
+ { tag: 'EthAppCommandError', errorCode: '6a80', message: 'Invalid data', reason: 'blind_signing' },
+ ])('classifies $tag (0x$errorCode) as $reason', ({ tag, errorCode, message, reason }) => {
+ expect(readLedgerDeviceError(deviceExchangeError(tag, errorCode, message)).reason).toBe(reason)
+ })
+
+ // Every runtime `_tag` the transport layer can raise. These are read off
+ // the shipped classes, not their export names: `OpeningConnectionError` is
+ // exported under a name that does not match its `_tag`, so trusting the
+ // export name left that state unmapped. Pinning all nine stops a rename or
+ // a typo from silently dropping one back to the fallback sentence.
+ it.each([
+ 'ConnectionOpeningError',
+ 'DeviceDisconnectedBeforeSendingApdu',
+ 'DeviceDisconnectedWhileSendingError',
+ 'DeviceNotRecognizedError',
+ 'DisconnectError',
+ 'NoAccessibleDeviceError',
+ 'ReconnectionFailedError',
+ 'SendApduTimeoutError',
+ 'WebHidSendReportError',
+ ])('classifies the transport failure %s as a lost connection', (tag) => {
+ const info = readLedgerDeviceError({ _tag: tag, originalError: new Error('boom') })
+
+ expect(info.reason).toBe('connection')
+ expect(getLedgerUserMessage(info)).toBe('Lost connection to your Ledger. Reconnect it and try again.')
+ })
+
+ it('does not mistake the SDK export name for the runtime tag', () => {
+ // `OpeningConnectionError` is the export; `ConnectionOpeningError` is what
+ // the instance actually carries. Only the latter may map.
+ expect(readLedgerDeviceError({ _tag: 'OpeningConnectionError' }).reason).toBe('unknown')
+ })
+
+ it('matches a status word whatever its hex casing', () => {
+ const info = readLedgerDeviceError({ _tag: 'EthAppCommandError', errorCode: '6A80' } as DmkError)
+
+ expect(info.reason).toBe('blind_signing')
+ expect(info.errorCode).toBe('6a80')
+ expect(getLedgerSupportReference(readLedgerDeviceError({ _tag: 'X', errorCode: '6F00' } as DmkError))).toBe(
+ 'LEDGER-0x6f00',
+ )
+ })
+
+ it('classifies a status word nested in originalError the same as a typed one', () => {
+ // An eth-app code raised outside the app's own table arrives as an
+ // UnknownDeviceExchangeError with a plain-object originalError.
+ const nested = readLedgerDeviceError({
+ _tag: 'UnknownDeviceExchangeError',
+ originalError: { message: 'UnknownError', errorCode: '6982' },
+ } as DmkError)
+
+ expect(nested.reason).toBe('rejected')
+ expect(nested.reason).toBe(readLedgerDeviceError(deviceExchangeError('EthAppCommandError', '6982', 'x')).reason)
+ })
+
+ it('classifies a tagless shape without throwing', () => {
+ expect(readLedgerDeviceError({} as DmkError)).toEqual({
+ source: 'ledger-device',
+ reason: 'unknown',
+ tag: 'UnknownDmkError',
+ errorCode: undefined,
+ deviceMessage: undefined,
+ })
+ })
+ })
+
+ describe('getLedgerUserMessage', () => {
+ it.each([
+ ['5501', 'Transaction rejected on your Ledger.'],
+ ['5515', 'Unlock your Ledger and try again.'],
+ ['6807', 'Open the Ethereum app on your Ledger.'],
+ ['6a80', 'Enable blind signing in the Ethereum app on your Ledger, then try again.'],
+ ])('translates the device state 0x%s', (errorCode, expected) => {
+ const info = readLedgerDeviceError(deviceExchangeError('EthAppCommandError', errorCode, 'raw device text'))
+ expect(getLedgerUserMessage(info)).toBe(expected)
+ })
+
+ it('translates a lost transport into a reconnect instruction', () => {
+ const info = readLedgerDeviceError({ _tag: 'DisconnectError' })
+ expect(getLedgerUserMessage(info)).toBe('Lost connection to your Ledger. Reconnect it and try again.')
+ })
+
+ it('falls back to one sentence plus a support reference for an unmapped state', () => {
+ const info = readLedgerDeviceError(invalidStatusWord('The context type [x] is not covered'))
+
+ expect(getLedgerUserMessage(info)).toBe('Your Ledger could not complete the request.')
+ expect(getLedgerSupportReference(info)).toBe('LEDGER-UNKNOWN')
+ })
+
+ it('puts the status word — and nothing else — in the support reference', () => {
+ const info = readLedgerDeviceError(deviceExchangeError('EthAppCommandError', '6f00', 'Technical problem'))
+
+ expect(info.reason).toBe('unknown')
+ expect(getLedgerSupportReference(info)).toBe('LEDGER-0x6f00')
+ })
+
+ it('never exposes internals in any translated sentence or reference', () => {
+ const errors: DmkError[] = [
+ invalidStatusWord('no signature returned'),
+ invalidStatusWord(),
+ deviceExchangeError('EthAppCommandError', '6985', 'Condition not satisfied'),
+ deviceExchangeError('DeviceLockedError', '5515', 'Device is locked.'),
+ deviceExchangeError('EthAppCommandError', '6a80', 'Invalid data'),
+ { _tag: 'WebHidSendReportError', originalError: new Error('The device is not connected') },
+ {} as DmkError,
+ ]
+
+ for (const error of errors) {
+ const info = readLedgerDeviceError(error)
+ const rendered = `${getLedgerUserMessage(info)} ${getLedgerSupportReference(info)}`
+
+ for (const forbidden of FORBIDDEN_IN_UI) {
+ expect(rendered).not.toContain(forbidden)
+ }
+ expect(rendered).not.toContain(info.tag)
+ expect(rendered).not.toBe('unknown')
+ }
+ })
+ })
+
+ describe('mapLedgerError', () => {
+ it('never emits the literal "unknown" for an InvalidStatusWordError', () => {
+ const error = mapLedgerError(invalidStatusWord('no signature returned')) as MappedError
+
+ expect(error.message).toBe('Your Ledger could not complete the request.')
+ expect(error.shortMessage).toBe('Your Ledger could not complete the request.')
+ expect(error.code).toBe('UNKNOWN_ERROR')
+ })
+
+ it('keeps the device reason on the error, and out of its message', () => {
+ const error = mapLedgerError(invalidStatusWord('V is missing'))
+
+ expect(getLedgerDeviceError(error)).toMatchObject({
+ tag: 'InvalidStatusWordError',
+ deviceMessage: 'V is missing',
+ })
+ // The evidence travels on the error, never in its message: `message` is
+ // what gets rendered, including by third-party UI we cannot intercept.
+ // `CodedException` reads it off the error and tags Datadog with it.
+ expect(error.message).not.toContain('V is missing')
+ expect(error.message).not.toContain('InvalidStatusWordError')
+ })
+
+ it('renders as the plain sentence wherever the raw message is shown', () => {
+ // `@web3-onboard/hw-common`'s account picker prints `err.message`
+ // verbatim, so the message itself has to be user-ready — ethers'
+ // `makeError` appended `info={…}, code=…, version=…` to it (WA-3243).
+ const errors = [
+ invalidStatusWord('no signature returned'),
+ invalidStatusWord(),
+ deviceExchangeError('DeviceLockedError', '5515', 'Device is locked.'),
+ deviceExchangeError('EthAppCommandError', '6a80', 'Invalid data'),
+ deviceExchangeError('OpenAppCommandError', '6807', 'Unknown application name'),
+ { _tag: 'WebHidSendReportError', originalError: new Error('The device is not connected') },
+ {} as DmkError,
+ ]
+
+ for (const dmkError of errors) {
+ const { message } = mapLedgerError(dmkError)
+
+ expect(message).toBe(getLedgerUserMessage(readLedgerDeviceError(dmkError)))
+ for (const forbidden of FORBIDDEN_IN_UI) {
+ expect(message).not.toContain(forbidden)
+ }
+ }
+ })
+
+ it('classifies a device failure as a Ledger error rather than unknown', () => {
+ const error = mapLedgerError(invalidStatusWord('no signature returned'))
+ const coded = `Code 804: Error executing a transaction (${wrapLikeViem(error).message})`
+
+ expect(normalizeError({ code: 804, message: coded, isUserFacing: true }).type).toBe(ErrorType.LEDGER_ERROR)
+ })
+
+ it.each([
+ { tag: 'EthAppCommandError', errorCode: '6985' },
+ { tag: 'DeviceInternalError', errorCode: '5501' },
+ { tag: 'EthAppCommandError', errorCode: '6982' },
+ ])('reports a rejection carried by $tag (0x$errorCode) as a rejection, not a failure', ({ tag, errorCode }) => {
+ const error = mapLedgerError(deviceExchangeError(tag, errorCode, 'raw device text')) as MappedError
+
+ expect(error.code).toBe('ACTION_REJECTED')
+ expect(isWalletRejection(error)).toBe(true)
+ expect(isWalletRejection(wrapLikeViem(error))).toBe(true)
+ expect(matchUserOutcome(wrapLikeViem(error).message)).toBe(ErrorType.USER_REJECTED)
+ })
+
+ it('reports a rejection signalled by tag alone as a rejection', () => {
+ const error = mapLedgerError({ _tag: 'RefusedByUserDAError' }) as MappedError
+
+ expect(error.code).toBe('ACTION_REJECTED')
+ expect(matchUserOutcome(error.message)).toBe(ErrorType.USER_REJECTED)
+ })
+
+ it('states a rejection in words a user can read, and analytics still counts it as one', () => {
+ // The account picker prints this message raw, so it cannot be ethers'
+ // `user rejected action`; `matchUserOutcome` keys off the wording, so it
+ // must still carry a phrase the matcher knows (WA-2950 / WA-3243).
+ const error = mapLedgerError({ _tag: 'RefusedByUserDAError' }) as MappedError
+
+ expect(error.message).toBe('You rejected the request on your Ledger.')
+ for (const forbidden of FORBIDDEN_IN_UI) {
+ expect(error.message).not.toContain(forbidden)
+ }
+ expect(matchUserOutcome(`Code 804: Error executing a transaction (${error.message})`)).toBe(
+ ErrorType.USER_REJECTED,
+ )
+ expect(isWalletRejection(wrapLikeViem(error))).toBe(true)
+ })
+
+ it('does not report a locked device as a rejection', () => {
+ const error = mapLedgerError(deviceExchangeError('DeviceLockedError', '5515', 'Device is locked.'))
+
+ expect(isWalletRejection(error)).toBe(false)
+ expect(matchUserOutcome(wrapLikeViem(error).message)).toBeUndefined()
+ })
+ })
+
+ describe('getLedgerDeviceError', () => {
+ it('finds the device reason through the viem re-wrap that reaches the UI', () => {
+ const wrapped = wrapLikeViem(
+ mapLedgerError(deviceExchangeError('DeviceLockedError', '5515', 'Device is locked.')),
+ )
+
+ expect(getLedgerDeviceError(wrapped)?.reason).toBe('locked')
+ })
+
+ it('finds the device reason through several layers of wrapping', () => {
+ const wrapped = Object.assign(new Error('Failed to execute transaction'), {
+ cause: wrapLikeViem(mapLedgerError(invalidStatusWord('no signature returned'))),
+ })
+
+ expect(getLedgerDeviceError(wrapped)?.tag).toBe('InvalidStatusWordError')
+ })
+
+ it('ignores errors that are not Ledger device failures', () => {
+ expect(getLedgerDeviceError(new Error('execution reverted'))).toBeUndefined()
+ expect(getLedgerDeviceError(Object.assign(new Error('rpc'), { info: { payload: {} } }))).toBeUndefined()
+ expect(getLedgerDeviceError(undefined)).toBeUndefined()
+ })
+
+ it('terminates on a self-referencing cause chain', () => {
+ const error: Error & { cause?: unknown } = new Error('loop')
+ error.cause = error
+
+ expect(getLedgerDeviceError(error)).toBeUndefined()
+ })
+ })
+})
diff --git a/apps/web/src/services/onboard/ledger-errors.ts b/apps/web/src/services/onboard/ledger-errors.ts
new file mode 100644
index 0000000000..030d8d4368
--- /dev/null
+++ b/apps/web/src/services/onboard/ledger-errors.ts
@@ -0,0 +1,245 @@
+/**
+ * Ledger's Device Management Kit reports failures as tagged objects, not as
+ * `Error`s: `{ _tag, originalError?, errorCode? }`. The device's own
+ * explanation lives in `message`, in `originalError.message`, or — for a status
+ * word the kit does not model — inside `originalError`. Dropping it (as a bare
+ * `'unknown'`) leaves both the user and Datadog with nothing to act on, and
+ * serialising the raw object leaks internals onto the screen (WA-3243).
+ *
+ * So: read the reason, classify it, translate it, and keep the raw evidence in
+ * the error's `info` payload, which reaches the debugging sinks but is never
+ * rendered.
+ */
+
+import type { DmkError } from '@ledgerhq/device-management-kit'
+
+import type { LedgerDeviceErrorInfo, LedgerDeviceErrorReason } from './types'
+
+/**
+ * The runtime marker written into every mapped error. Annotated with the
+ * interface's own field type so the const and the type cannot drift apart
+ * silently — that marker is what lets `getLedgerDeviceError` recognise the
+ * payload after viem re-wraps the error, so a mismatch would quietly disable
+ * the whole feature.
+ */
+const LEDGER_ERROR_SOURCE: LedgerDeviceErrorInfo['source'] = 'ledger-device'
+
+/**
+ * APDU status words. The Ethereum app and the DMK global handler both report
+ * these; the same word means the same thing whichever class carries it.
+ */
+const StatusWord = {
+ ACTION_REFUSED: '5501',
+ DEVICE_LOCKED: '5515',
+ SECURITY_STATUS_NOT_SATISFIED: '6982',
+ CONDITION_NOT_SATISFIED: '6985',
+ APP_NOT_OPEN: '6511',
+ UNKNOWN_APP: '6807',
+ NO_APP_NAME: '670a',
+ INVALID_DATA: '6a80',
+ INS_NOT_SUPPORTED: '6d00',
+ CLA_NOT_SUPPORTED: '6e00',
+} as const
+
+const REJECTION_CODES: ReadonlySet = new Set([
+ StatusWord.ACTION_REFUSED,
+ StatusWord.SECURITY_STATUS_NOT_SATISFIED,
+ StatusWord.CONDITION_NOT_SATISFIED,
+])
+
+const APP_CODES: ReadonlySet = new Set([
+ StatusWord.APP_NOT_OPEN,
+ StatusWord.UNKNOWN_APP,
+ StatusWord.NO_APP_NAME,
+ StatusWord.INS_NOT_SUPPORTED,
+ StatusWord.CLA_NOT_SUPPORTED,
+])
+
+/** DMK tags that carry a rejection without a status word. */
+const REJECTION_TAGS: ReadonlySet = new Set(['ActionRefusedError', 'RefusedByUserDAError'])
+
+const LOCKED_TAGS: ReadonlySet = new Set(['DeviceLockedError'])
+
+const APP_TAGS: ReadonlySet = new Set(['OpenAppCommandError'])
+
+/**
+ * Transport-level tags: the cable, the WebHID handle or the session went away.
+ *
+ * These are runtime `_tag` values, which are not always the SDK's export name —
+ * `OpeningConnectionError` declares `_tag = 'ConnectionOpeningError'`. Every
+ * entry here was read off the shipped class, not off the export, and the table
+ * test below pins all nine so a rename cannot silently un-map one.
+ */
+const CONNECTION_TAGS: ReadonlySet = new Set([
+ 'ConnectionOpeningError',
+ 'DeviceDisconnectedBeforeSendingApdu',
+ 'DeviceDisconnectedWhileSendingError',
+ 'DeviceNotRecognizedError',
+ 'DisconnectError',
+ 'NoAccessibleDeviceError',
+ 'ReconnectionFailedError',
+ 'SendApduTimeoutError',
+ 'WebHidSendReportError',
+])
+
+const USER_MESSAGES: Record = {
+ rejected: 'Transaction rejected on your Ledger.',
+ locked: 'Unlock your Ledger and try again.',
+ app_closed: 'Open the Ethereum app on your Ledger.',
+ blind_signing: 'Enable blind signing in the Ethereum app on your Ledger, then try again.',
+ connection: 'Lost connection to your Ledger. Reconnect it and try again.',
+ unknown: 'Your Ledger could not complete the request.',
+}
+
+/**
+ * The message a rejection carries. Two constraints meet here: `matchUserOutcome`
+ * classifies purely on wording, so the phrase "rejected the request" has to
+ * survive verbatim or a cancellation starts counting as a failure (WA-2950) —
+ * and the account picker renders this string raw, so it also has to read as
+ * copy. ethers' own `user rejected action` satisfied only the first.
+ *
+ * The tx and message flows show `USER_MESSAGES.rejected` instead; they resolve
+ * the sentence from the reason rather than from this message.
+ */
+const REJECTION_MESSAGE = 'You rejected the request on your Ledger.'
+
+const readString = (source: unknown, key: string): string | undefined => {
+ if (typeof source !== 'object' || source === null) return undefined
+ const value = (source as Record)[key]
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+/**
+ * The status word, wherever the kit put it: on the error for a
+ * `DeviceExchangeError`, nested in `originalError` for the status words it
+ * does not model (`UnknownDeviceExchangeError`).
+ *
+ * A status word nested in `originalError` is worth noting: an eth-app code that
+ * arrives outside the app's own table (e.g. `6982` raised by the global
+ * handler) becomes an `UnknownDeviceExchangeError` whose plain-object
+ * `originalError` still carries the code, so it classifies the same way it
+ * would have on the typed class — a `6982` there maps to `rejected` and is
+ * suppressed everywhere. No shipped DMK-supported firmware does this today
+ * (locked is signalled by `5515` exclusively), but reading the code from both
+ * places is what keeps the two paths consistent if one ever does.
+ *
+ * Lower-cased because the comparison tables are lower-case hex. The kit emits
+ * lower case today (`bufferToHexaString` goes through `toString(16)`); this is
+ * insurance, and the casing of a hex status word carries no information.
+ */
+const readErrorCode = (error: DmkError): string | undefined =>
+ (readString(error, 'errorCode') ?? readString(error.originalError, 'errorCode'))?.toLowerCase()
+
+/**
+ * The device's own explanation. `InvalidStatusWordError` has no `message` at
+ * all — its text is wrapped in an `Error` under `originalError`, which
+ * serialises to `{}` and is why these failures reached users as `unknown`.
+ */
+const readDeviceMessage = (error: DmkError): string | undefined =>
+ readString(error, 'message') ?? readString(error.originalError, 'message')
+
+const resolveReason = (tag: string, errorCode: string | undefined): LedgerDeviceErrorReason => {
+ if (REJECTION_TAGS.has(tag) || (errorCode && REJECTION_CODES.has(errorCode))) return 'rejected'
+ if (LOCKED_TAGS.has(tag) || errorCode === StatusWord.DEVICE_LOCKED) return 'locked'
+ if (APP_TAGS.has(tag) || (errorCode && APP_CODES.has(errorCode))) return 'app_closed'
+ if (errorCode === StatusWord.INVALID_DATA) return 'blind_signing'
+ if (CONNECTION_TAGS.has(tag)) return 'connection'
+ return 'unknown'
+}
+
+/** Reads what the device said, without interpreting it. */
+export const readLedgerDeviceError = (error: DmkError): LedgerDeviceErrorInfo => {
+ const tag = readString(error, '_tag') ?? 'UnknownDmkError'
+ const errorCode = readErrorCode(error)
+
+ return {
+ source: LEDGER_ERROR_SOURCE,
+ reason: resolveReason(tag, errorCode),
+ tag,
+ errorCode,
+ deviceMessage: readDeviceMessage(error),
+ }
+}
+
+/** The sentence shown to the user. Never contains device or library internals. */
+export const getLedgerUserMessage = (info: LedgerDeviceErrorInfo): string => USER_MESSAGES[info.reason]
+
+/**
+ * Support reference for a device failure we have no sentence for. Only the
+ * status word is exposed — the tag and the device's raw words stay in
+ * telemetry, as they name internals.
+ */
+export const getLedgerSupportReference = (info: LedgerDeviceErrorInfo): string =>
+ `LEDGER-${info.errorCode ? `0x${info.errorCode}` : 'UNKNOWN'}`
+
+const isLedgerDeviceErrorInfo = (value: unknown): value is LedgerDeviceErrorInfo =>
+ typeof value === 'object' && value !== null && (value as LedgerDeviceErrorInfo).source === LEDGER_ERROR_SOURCE
+
+/** Guards against a self-referencing cause chain. */
+const MAX_CAUSE_DEPTH = 10
+
+/**
+ * Recovers the device reason from an error that has since been re-wrapped —
+ * ethers hands the rejection to viem, which wraps it in `UnknownRpcError`, and
+ * protocol-kit may wrap that again. Each wrapper keeps the previous error as
+ * `cause`, so the payload is always reachable from the chain.
+ */
+export const getLedgerDeviceError = (error: unknown): LedgerDeviceErrorInfo | undefined => {
+ let current: unknown = error
+
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === 'object' && current !== null; depth++) {
+ const { info, cause } = current as { info?: unknown; cause?: unknown }
+ if (isLedgerDeviceErrorInfo(info)) return info
+ current = cause
+ }
+
+ return undefined
+}
+
+/** ethers error codes our consumers key off. */
+type LedgerErrorCode = 'ACTION_REJECTED' | 'UNKNOWN_ERROR'
+
+interface LedgerErrorFields {
+ readonly code: LedgerErrorCode
+ /** ethers' own name for the untouched sentence; kept so `isError`-style consumers still find it. */
+ readonly shortMessage: string
+ readonly info: LedgerDeviceErrorInfo
+ /** ethers' `ActionRejectedError` shape, preserved for anything matching on it. */
+ readonly action?: 'unknown'
+ readonly reason?: 'rejected'
+}
+
+/**
+ * Builds the error by hand rather than 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 — the
+ * wallet-connect account picker (`@web3-onboard/hw-common`) prints it verbatim
+ * and we cannot intercept it — so the sentence has to BE the message, and the
+ * evidence has to ride on fields nothing renders (WA-3243).
+ */
+const buildLedgerError = (message: string, fields: LedgerErrorFields): Error =>
+ Object.assign(new Error(message), fields)
+
+/**
+ * Converts a DMK failure into the error the EIP-1193 provider must reject with.
+ *
+ * The message is the user-facing sentence and nothing else, so it reads
+ * correctly wherever it is rendered raw; the device's own words ride along in
+ * `info`, which the debugging sinks read and no renderer touches.
+ */
+export const mapLedgerError = (error: DmkError): Error => {
+ const info = readLedgerDeviceError(error)
+
+ if (info.reason === 'rejected') {
+ return buildLedgerError(REJECTION_MESSAGE, {
+ code: 'ACTION_REJECTED',
+ shortMessage: REJECTION_MESSAGE,
+ action: 'unknown',
+ reason: 'rejected',
+ info,
+ })
+ }
+
+ const message = getLedgerUserMessage(info)
+ return buildLedgerError(message, { code: 'UNKNOWN_ERROR', shortMessage: message, info })
+}
diff --git a/apps/web/src/services/onboard/ledger-module.ts b/apps/web/src/services/onboard/ledger-module.ts
index 2703065d83..a714e74ace 100644
--- a/apps/web/src/services/onboard/ledger-module.ts
+++ b/apps/web/src/services/onboard/ledger-module.ts
@@ -1,5 +1,6 @@
import type { DmkError, ExecuteDeviceActionReturnType } from '@ledgerhq/device-management-kit'
-import { makeError } from 'ethers'
+import { mapLedgerError } from './ledger-errors'
+import { styleAccountSelectAlert } from './accountSelectAlert'
import type {
GetAddressDAOutput,
SignPersonalMessageDAOutput,
@@ -251,13 +252,19 @@ export function ledgerModule(): WalletInit {
* and sets the first account as the current account
*/
async function getAccounts(): Promise> {
- const accounts = await accountSelect({
+ const selection = accountSelect({
basePaths: DEFAULT_BASE_PATHS,
assets: DEFAULT_ASSETS,
chains,
scanAccounts: deriveAccounts,
})
+ // `accountSelect` mounts its widget synchronously before its first
+ // await, so the shadow root is already there to be styled (WA-3243).
+ styleAccountSelectAlert()
+
+ const accounts = await selection
+
if (accounts.length > 0) {
setCurrentAccount(accounts[0])
}
@@ -339,10 +346,6 @@ export function ledgerModule(): WalletInit {
}
}
-const enum LedgerErrorCode {
- REJECTED = '6985',
-}
-
// Promisified Ledger SDK
async function getLedgerSdk() {
const { DeviceManagementKitBuilder } = await import('@ledgerhq/device-management-kit')
@@ -389,7 +392,7 @@ async function waitForAction