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( if (actionState.status === DeviceActionStatus.Completed) { resolve(actionState.output) } else if (actionState.status === DeviceActionStatus.Error) { - reject(mapEthersError(actionState.error)) + reject(mapLedgerError(actionState.error)) } else { // Awaiting user action, e.g. device to be unlocked. We could throw // an explicit error message but we keep the signing request alive @@ -401,19 +404,3 @@ async function waitForAction( subscription?.unsubscribe() } } - -function mapEthersError(error: DmkError) { - const isRejection = 'errorCode' in error ? error.errorCode === LedgerErrorCode.REJECTED : false - - if (!isRejection) { - return makeError(error.message ?? 'unknown', 'UNKNOWN_ERROR', { - info: error, - }) - } - - return makeError('user rejected action', 'ACTION_REJECTED', { - action: 'unknown', - reason: 'rejected', - info: error, - }) -} diff --git a/apps/web/src/services/onboard/types.ts b/apps/web/src/services/onboard/types.ts new file mode 100644 index 0000000000..0e3ca51f7f --- /dev/null +++ b/apps/web/src/services/onboard/types.ts @@ -0,0 +1,18 @@ +/** What the device is actually telling us, in terms a user can act on. */ +export type LedgerDeviceErrorReason = 'rejected' | 'locked' | 'app_closed' | 'blind_signing' | 'connection' | 'unknown' + +/** + * Marks an ethers error as originating from a Ledger device. Attached as the + * error's `info` so it survives every re-wrap (ethers → viem → protocol-kit) + * and can be recovered from the cause chain at the point of display. + */ +export interface LedgerDeviceErrorInfo { + readonly source: 'ledger-device' + readonly reason: LedgerDeviceErrorReason + /** DMK error class discriminator, e.g. `InvalidStatusWordError`. Debugging sinks only. */ + readonly tag: string + /** APDU status word as reported by the device, e.g. `5515`. */ + readonly errorCode?: string + /** The device's own words, e.g. `no signature returned`. Debugging sinks only. */ + readonly deviceMessage?: string +}