Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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(
<ErrorMessage error={error}>Unlock your Ledger and try again.</ErrorMessage>,
)

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(
<ErrorMessage error={error}>Your Ledger could not complete the request.</ErrorMessage>,
)

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',
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/tx/ErrorMessage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -92,7 +101,7 @@ const ErrorMessage = ({
</span>
)}

{error && !effectiveGsCode && (
{error && !effectiveGsCode && !ledgerError && (
<Link
render={<button type="button" />}
onClick={onDetailsToggle}
Expand All @@ -105,6 +114,8 @@ const ErrorMessage = ({

{effectiveGsCode ? (
<ErrorDetails code={effectiveGsCode} customError={customError} />
) : ledgerError ? (
ledgerReference && <ErrorDetails code={ledgerReference} />
) : (
error &&
showDetails && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof mapLedgerError>[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(<TxSubmitError error={new Gs026PreCheckError('STALE_NONCE')} />)
Expand Down Expand Up @@ -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(<TxSubmitError error={error} />)

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(<TxSubmitError error={error} />)

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')

Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/tx/TxSubmitError/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -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 (
<ErrorMessage error={error} level="error" context={context}>
{getLedgerUserMessage(ledgerError)}
</ErrorMessage>
)
}

// A failed GS026 pre-check blocked the broadcast — show its specific,
// cause-aware message (stale nonce / not a signer / bad signature).
if (isGs026PreCheckError(error)) {
Expand Down
145 changes: 145 additions & 0 deletions apps/web/src/hooks/__tests__/useTxNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -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 <pre> — 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.',
}),
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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())

Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/hooks/messages/useSafeMessageNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<SafeMsgEvent, string>> = {
[SafeMsgEvent.PROPOSE]: 'You successfully signed the message.',
Expand Down Expand Up @@ -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',
}),
Expand Down
Loading
Loading