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
10 changes: 6 additions & 4 deletions apps/web/src/components/transactions/SingleTx/SingleTx.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,12 @@ describe('SingleTx', () => {
expect(screen.getByText('Failed to load transaction')).toBeInTheDocument()
})

await waitFor(() => {
fireEvent.click(screen.getByText('Details'))
expect(screen.getByText('Server error')).toBeInTheDocument()
})
// A known CGW response state shows the code-only support reference instead
// of a Details toggle revealing the raw response (WA-3252).
expect(screen.getByTestId('error-details')).toBeInTheDocument()
expect(screen.getByText('CGW-500')).toBeInTheDocument()
expect(screen.queryByText('Details')).not.toBeInTheDocument()
expect(screen.queryByText('Server error')).not.toBeInTheDocument()
})

it('shows an error when transaction is not from the opened Safe', async () => {
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/tx/ErrorMessage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ReactElement, type ReactNode, type SyntheticEvent, useState } from 'react'
import { getGsCodeFromError } from '@safe-global/utils/services/exceptions/contractErrors'
import { getGuardErrorInfo, isRevertError } from '@/utils/transaction-errors'
import { getCgwSupportCode } from '@/utils/cgw-errors'
import { decodeCustomError } from '@/utils/customErrorRegistry'
import { getBlockExplorerLink } from '@/utils/chains'
import useSafeInfo from '@/hooks/useSafeInfo'
Expand Down Expand Up @@ -52,6 +53,11 @@ const ErrorMessage = ({
error && (gsCode === 'GS013' || (!gsCode && isRevertError(error))) ? decodeCustomError(error) : undefined
const effectiveGsCode = gsCode ?? (customError ? 'GS013' : undefined)

// A known CGW response state (429/422/451/5xx) gets the same code-only
// support reference, so the raw response body — which can be a gateway's HTML
// error page — is never rendered in Details (WA-3252).
const supportCode = effectiveGsCode ?? (error ? getCgwSupportCode(error) : undefined)

// Check if this is a Guard error that should get special treatment
const guardErrorName = error && context ? getGuardErrorInfo(error) : undefined
const guardExplorerLink =
Expand Down Expand Up @@ -92,7 +98,7 @@ const ErrorMessage = ({
</span>
)}

{error && !effectiveGsCode && (
{error && !supportCode && (
<Link
render={<button type="button" />}
onClick={onDetailsToggle}
Expand All @@ -103,8 +109,8 @@ const ErrorMessage = ({
)}
</span>

{effectiveGsCode ? (
<ErrorDetails code={effectiveGsCode} customError={customError} />
{supportCode ? (
<ErrorDetails code={supportCode} customError={customError} />
) : (
error &&
showDetails && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ 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 { BaseError } from 'viem'
import { asError } from '@safe-global/utils/services/exceptions/utils'
import { RATE_LIMIT_USER_MESSAGE } from '@/utils/transaction-errors'
import TxSubmitError from '..'

const HTML_502 =
'<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center></body></html>'

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 @@ -66,4 +72,75 @@ describe('TxSubmitError', () => {

expect(getByText('Could not submit the transaction. Try again.')).toBeInTheDocument()
})

describe('CGW response states (WA-3252)', () => {
it.each([429, 502, 500, 503, 422])('renders the agreed copy for a %s from CGW', (status) => {
const error = asError({ status, data: {} })

const { getByText } = render(<TxSubmitError error={error} />)

expect(getByText('Something went wrong on our end. Try again.')).toBeInTheDocument()
})

it('renders the agreed copy — not the raw HTML — for the original 502 defect', () => {
const error = asError({
status: 'PARSING_ERROR',
originalStatus: 502,
data: HTML_502,
error: "SyntaxError: Unexpected token '<'",
})

const { getByText, queryByText, container } = render(<TxSubmitError error={error} />)

expect(getByText('Something went wrong on our end. Try again.')).toBeInTheDocument()
expect(container.textContent).not.toContain('Bad Gateway')
expect(container.textContent).not.toContain('nginx')
expect(container.textContent).not.toContain('<')
expect(queryByText(/Could not submit/)).not.toBeInTheDocument()
})

it('renders the banned-Safe copy for a 451', () => {
const error = asError({ status: 451, data: {} })

const { getByText } = render(<TxSubmitError error={error} />)

expect(getByText('This Safe Account is not available.')).toBeInTheDocument()
})

it('shows a code-only support reference instead of a raw Details payload', () => {
const error = asError({
status: 'PARSING_ERROR',
originalStatus: 502,
data: HTML_502,
error: "SyntaxError: Unexpected token '<'",
})

const { getByTestId, getByText, queryByText } = render(<TxSubmitError error={error} />)

expect(getByTestId('error-details')).toBeInTheDocument()
expect(getByText('CGW-502')).toBeInTheDocument()
expect(queryByText('Details')).not.toBeInTheDocument()
})

it('leaves a 404 alone — out of scope for this mapping', () => {
const error = asError({ status: 404, data: {} })

const { getByText, queryByText } = render(<TxSubmitError error={error} />)

expect(getByText('Could not submit the transaction. Try again.')).toBeInTheDocument()
expect(queryByText(/on our end/)).not.toBeInTheDocument()
})

it('prefers the rate-limit copy over the CGW copy for a 429-carrying error, as the toast does', () => {
// Counterpart of the same-named test in `useTxNotifications`: a throttled
// request matches both classifiers, and both surfaces must resolve it the
// same way (WA-3252).
const error = Object.assign(new BaseError('HTTP request failed.'), { status: 429 })

const { getByText, queryByText } = render(<TxSubmitError error={error} />)

expect(getByText(RATE_LIMIT_USER_MESSAGE)).toBeInTheDocument()
expect(queryByText(/on our end/)).not.toBeInTheDocument()
})
})
})
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 @@ -9,6 +9,7 @@ import {
} from '@/utils/transaction-errors'
import { didRevert, type EthersError } from '@/utils/ethers-utils'
import { isGs026PreCheckError } from '@/services/tx/executionPreChecks'
import { getCgwErrorInfo } from '@/utils/cgw-errors'
import ErrorMessage from '@/components/tx/ErrorMessage'

export const COULD_NOT_SUBMIT_MESSAGE = 'Could not submit the transaction.'
Expand Down Expand Up @@ -65,6 +66,18 @@ const TxSubmitError = ({
)
}

// The Safe Client Gateway answered with a known response state. Show the
// agreed copy — never the response body, which can be an HTML error page —
// and let ErrorMessage render the code-only support reference (WA-3252).
const cgwError = getCgwErrorInfo(error)
if (cgwError) {
return (
<ErrorMessage error={error} level="error" context={context}>
{cgwError.message}
</ErrorMessage>
)
}

// Only a mined receipt with a reverted status proves gas was actually spent.
if (didRevert((error as EthersError).receipt)) {
return (
Expand Down
129 changes: 129 additions & 0 deletions apps/web/src/hooks/__tests__/useTxNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { act } from 'react'
import { renderHook, waitFor } from '@/tests/test-utils'
import { BaseError } from 'viem'
import { asError } from '@safe-global/utils/services/exceptions/utils'
import { RATE_LIMIT_USER_MESSAGE } from '@/utils/transaction-errors'
import { CGW_ERROR_FALLBACK } from '@safe-global/utils/services/exceptions/gatewayErrors'
import useTxNotifications from '../useTxNotifications'
import { showNotification } from '@/store/notificationsSlice'
import { TxEvent, txDispatch } from '@/services/tx/txEvents'
import { chainBuilder } from '@/tests/builders/chains'

const chain = chainBuilder().with({ chainId: '11155111', chainName: 'Sepolia' }).build()

jest.mock('@/store/notificationsSlice', () => {
const original = jest.requireActual('@/store/notificationsSlice')
return {
...original,
showNotification: jest.fn(original.showNotification),
}
})

jest.mock('../useChains', () => ({
__esModule: true,
useCurrentChain: jest.fn(() => chain),
default: jest.fn(),
}))

jest.mock('../useTxQueue', () => ({
__esModule: true,
default: jest.fn(() => ({ page: undefined })),
}))

jest.mock('../useIsSafeOwner', () => ({
__esModule: true,
default: jest.fn(() => false),
}))

jest.mock('@safe-global/store/gateway/AUTO_GENERATED/transactions', () => ({
...jest.requireActual('@safe-global/store/gateway/AUTO_GENERATED/transactions'),
useLazyTransactionsGetTransactionByIdV1Query: jest.fn(() => [jest.fn(() => Promise.resolve({ data: undefined }))]),
}))

const HTML_502 =
'<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center></body></html>'

const lastNotification = () => {
const calls = (showNotification as unknown as jest.Mock).mock.calls
return calls[calls.length - 1][0]
}

describe('useTxNotifications — CGW response states (WA-3252)', () => {
beforeEach(() => {
jest.clearAllMocks()
})

const dispatchProposeFailure = async (error: Error) => {
renderHook(() => useTxNotifications())

act(() => {
txDispatch(TxEvent.PROPOSE_FAILED, { error })
})

await waitFor(() => expect(showNotification).toHaveBeenCalled())
}

it.each([429, 502, 500, 503, 422])('shows the agreed copy for a %s from CGW', async (status) => {
await dispatchProposeFailure(asError({ status, data: {} }))

expect(lastNotification().message).toBe('Something went wrong on our end. Try again.')
})

it('never leaks the raw HTML body of a 502 into the toast (the original defect)', async () => {
await dispatchProposeFailure(
asError({
status: 'PARSING_ERROR',
originalStatus: 502,
data: HTML_502,
error: "SyntaxError: Unexpected token '<'",
}),
)

const notification = lastNotification()
expect(notification.message).toBe('Something went wrong on our end. Try again.')
expect(notification.detailedMessage).toBe('Error code CGW-502')
expect(JSON.stringify(notification)).not.toContain('nginx')
expect(JSON.stringify(notification)).not.toContain('Bad Gateway')
expect(JSON.stringify(notification)).not.toContain('<html')
})

it('surfaces exactly one toast per 422, carrying the agreed copy', async () => {
// A 422 is our bug, not a transient failure. This pins one dispatch to one
// toast with the agreed copy; it does not — and cannot — observe whether a
// caller re-issues the request, so it says nothing about looping.
await dispatchProposeFailure(asError({ status: 422, data: {} }))

expect(showNotification).toHaveBeenCalledTimes(1)
expect(lastNotification().message).toBe(CGW_ERROR_FALLBACK)
})

it('shows the banned-Safe copy for a 451', async () => {
await dispatchProposeFailure(asError({ status: 451, data: {} }))

expect(lastNotification().message).toBe('This Safe Account is not available.')
})

it('leaves an unmapped failure (404) on the existing copy', async () => {
await dispatchProposeFailure(asError({ status: 404, data: { message: 'Not found' } }))

const notification = lastNotification()
expect(notification.message).toContain('Failed to add to queue')
expect(notification.detailedMessage).toBe('Not found')
})

it('prefers the rate-limit copy over the CGW copy for a 429-carrying error, as the inline alert does', async () => {
// A throttled request matches both classifiers: it is a viem rate-limit
// error AND an HTTP 429 the CGW map covers. `TxSubmitError` answers with
// the rate-limit copy, so the toast must too — otherwise one failure reads
// two different ways depending on where the user sees it (WA-3252).
// Swapping these two branches back breaks this test.
await dispatchProposeFailure(Object.assign(new BaseError('HTTP request failed.'), { status: 429 }))

const notification = lastNotification()
expect(notification.message).toBe(RATE_LIMIT_USER_MESSAGE)
expect(notification.message).not.toBe(CGW_ERROR_FALLBACK)
// The support reference still carries the status, exactly as the inline
// alert's code-only reference does.
expect(notification.detailedMessage).toBe('Error code CGW-429')
})
})
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 { asError } from '@safe-global/utils/services/exceptions/utils'

jest.mock('@/store/notificationsSlice', () => {
const original = jest.requireActual('@/store/notificationsSlice')
Expand Down Expand Up @@ -168,4 +169,46 @@ describe('useSafeMessageNotifications', () => {
variant: 'success',
})
})

describe('CGW response states (WA-3252)', () => {
const HTML_502 =
'<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center></body></html>'

it('shows the agreed copy and no raw HTML for a 502 from CGW', () => {
renderHook(() => useSafeMessageNotifications())

safeMsgDispatch(SafeMsgEvent.PROPOSE_FAILED, {
messageHash: '0x345',
error: asError({
status: 'PARSING_ERROR',
originalStatus: 502,
data: HTML_502,
error: "SyntaxError: Unexpected token '<'",
}),
})

expect(showNotification).toHaveBeenCalledWith({
message: 'Something went wrong on our end. Try again.',
detailedMessage: 'Error code CGW-502',
groupKey: '0x345',
variant: 'error',
})
})

it('shows the banned-Safe copy for a 451', () => {
renderHook(() => useSafeMessageNotifications())

safeMsgDispatch(SafeMsgEvent.PROPOSE_FAILED, {
messageHash: '0x346',
error: asError({ status: 451, data: {} }),
})

expect(showNotification).toHaveBeenCalledWith({
message: 'This Safe Account is not available.',
detailedMessage: 'Error code CGW-451',
groupKey: '0x346',
variant: 'error',
})
})
})
})
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 { getCgwErrorInfo } from '@/utils/cgw-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 known CGW response state replaces both the copy and the details:
// the response body can be a gateway HTML error page (WA-3252).
const cgwError = isError ? getCgwErrorInfo(detail.error) : undefined
const message = cgwError
? cgwError.message
: isError
? `${baseMessage}${formatError(detail.error)}`
: baseMessage

dispatch(
showNotification({
message,
detailedMessage: isError ? detail.error.message : undefined,
detailedMessage: cgwError ? `Error code ${cgwError.code}` : isError ? detail.error.message : undefined,
groupKey: detail.messageHash,
variant: isError ? 'error' : isSuccess ? 'success' : 'info',
}),
Expand Down
Loading
Loading