diff --git a/shared/lib/error.test.ts b/shared/lib/error.test.ts index b97d5fd457dc..4686c38f3fe2 100644 --- a/shared/lib/error.test.ts +++ b/shared/lib/error.test.ts @@ -3,6 +3,7 @@ import { isErrorWithMessage, logErrorWithMessage, createErrorFromNetworkRequest, + getErrorBodyMessage, } from './error'; jest.mock('loglevel'); @@ -34,6 +35,20 @@ describe('error module', () => { }); }); + describe('getErrorBodyMessage', () => { + it('returns the message from an error body', () => { + expect(getErrorBodyMessage({ message: 'Bad request' })).toBe( + 'Bad request', + ); + }); + + it('returns undefined when the body has no message string', () => { + expect(getErrorBodyMessage({ message: 400 })).toBeUndefined(); + expect(getErrorBodyMessage({ error: 'Bad request' })).toBeUndefined(); + expect(getErrorBodyMessage(undefined)).toBeUndefined(); + }); + }); + describe('createErrorFromNetworkRequest', () => { it('parses JSON response with error field', async () => { const response = { diff --git a/shared/lib/error.ts b/shared/lib/error.ts index e63e05e2f0ed..00809d6ce18d 100644 --- a/shared/lib/error.ts +++ b/shared/lib/error.ts @@ -32,6 +32,15 @@ export function logErrorWithMessage(error: unknown) { log.error(isErrorWithMessage(error) ? getErrorMessage(error) : error); } +export function getErrorBodyMessage(body: unknown) { + if (!body || typeof body !== 'object' || !('message' in body)) { + return undefined; + } + + const { message } = body as { message?: unknown }; + return typeof message === 'string' ? message : undefined; +} + export enum OAuthErrorMessages { // Error message for Authentication Server when failed to get the auth token // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 diff --git a/ui/components/multichain/activity-v2/useTransactionsQuery.test.ts b/ui/components/multichain/activity-v2/useTransactionsQuery.test.ts index d2164fc5f605..64a2d971035f 100644 --- a/ui/components/multichain/activity-v2/useTransactionsQuery.test.ts +++ b/ui/components/multichain/activity-v2/useTransactionsQuery.test.ts @@ -5,6 +5,7 @@ import { act, renderHook as renderHookBase, } from '@testing-library/react-hooks'; +import { HttpError } from '@metamask/core-backend'; import { usePrefetchTransactions, useTransactionsQuery, @@ -31,6 +32,13 @@ jest.mock('../../../helpers/api-client', () => ({ const selectedAddress = '0x4f5243ceea96cee1da0fdb89c756d0e999439424'; const expectedEvmAddress = selectedAddress; const expectedNetworks = ['eip155:1']; +const emptyResponse = { + data: [], + pageInfo: { + count: 0, + hasNextPage: false, + }, +}; const mockStore = configureMockStore()({ localeMessages: { currentLocale: 'en_GB', @@ -82,6 +90,54 @@ afterEach(() => { }); describe('useTransactionsQuery', () => { + it('normalizes unsupported network api errors to an empty response', async () => { + const unsupportedNetworksError = new HttpError( + 'http 400: Bad Request', + 400, + 'Bad Request', + 'https://accounts.api.cx.metamask.io/v4/multiaccount/transactions', + { + statusCode: 400, + message: + 'networks param contains no supported chains: eip155:43114, eip155:324', + }, + ); + mockGetV4MultiAccountTransactionsInfiniteQueryOptions.mockReturnValue({ + queryKey: ['transactions'], + queryFn: jest.fn().mockRejectedValue(unsupportedNetworksError), + getNextPageParam: jest.fn(), + enabled: true, + }); + + renderQueryHook(() => useTransactionsQuery()); + + const queryOptions = mockUseInfiniteQuery.mock.calls[0][0]; + await expect(queryOptions.queryFn({})).resolves.toStrictEqual( + emptyResponse, + ); + }); + + it('keeps unexpected api errors as query errors', async () => { + const unexpectedError = new HttpError( + 'http 500: Internal Server Error', + 500, + 'Internal Server Error', + 'https://accounts.api.cx.metamask.io/v4/multiaccount/transactions', + { statusCode: 500, message: 'Unexpected error' }, + ); + mockGetV4MultiAccountTransactionsInfiniteQueryOptions.mockReturnValue({ + queryKey: ['transactions'], + queryFn: jest.fn().mockRejectedValue(unexpectedError), + getNextPageParam: jest.fn(), + enabled: true, + }); + + renderQueryHook(() => useTransactionsQuery()); + + const queryOptions = mockUseInfiniteQuery.mock.calls[0][0]; + await expect(queryOptions.queryFn({})).rejects.toBe(unexpectedError); + }); + it('composes query options and delegates to useInfiniteQuery', () => { renderQueryHook(() => useTransactionsQuery()); @@ -171,7 +227,53 @@ describe('usePrefetchTransactions', () => { }); expect(mockQueryClient.prefetchInfiniteQuery).toHaveBeenCalledWith( - expect.objectContaining({ ...queryOptions, staleTime: 300000 }), + expect.objectContaining({ + queryKey: queryOptions.queryKey, + queryFn: expect.any(Function), + getNextPageParam: queryOptions.getNextPageParam, + enabled: true, + retry: false, + staleTime: 300000, + }), + ); + }); + + it('prefetches unsupported networks as an empty response', async () => { + const unsupportedNetworksError = new HttpError( + 'http 400: Bad Request', + 400, + 'Bad Request', + 'https://accounts.api.cx.metamask.io/v4/multiaccount/transactions', + { + statusCode: 400, + message: 'networks param contains no supported chains: eip155:43114', + }, + ); + const mockQueryClient = { + getQueryData: jest.fn().mockReturnValue(undefined), + isFetching: jest.fn().mockReturnValue(0), + prefetchInfiniteQuery: jest.fn(({ queryFn }) => queryFn({})), + }; + const queryOptions = { + queryKey: ['transactions'], + queryFn: jest.fn().mockRejectedValue(unsupportedNetworksError), + getNextPageParam: jest.fn(), + enabled: true, + }; + + mockUseQueryClient.mockReturnValue(mockQueryClient); + mockGetV4MultiAccountTransactionsInfiniteQueryOptions.mockReturnValue( + queryOptions, ); + + const { result } = renderQueryHook(() => usePrefetchTransactions()); + + act(() => { + result.current(); + }); + + await expect( + mockQueryClient.prefetchInfiniteQuery.mock.results[0].value, + ).resolves.toStrictEqual(emptyResponse); }); }); diff --git a/ui/components/multichain/activity-v2/useTransactionsQuery.ts b/ui/components/multichain/activity-v2/useTransactionsQuery.ts index ee5c26bf3d03..b3fce3678d52 100644 --- a/ui/components/multichain/activity-v2/useTransactionsQuery.ts +++ b/ui/components/multichain/activity-v2/useTransactionsQuery.ts @@ -1,7 +1,9 @@ import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { HttpError } from '@metamask/core-backend'; import type { CaipChainId } from '@metamask/utils'; +import { getErrorBodyMessage } from '../../../../shared/lib/error'; import { selectTransactions } from '../../../../shared/lib/multichain/transformations'; import { MINUTE } from '../../../../shared/constants/time'; import { getUseExternalServices } from '../../../selectors'; @@ -12,6 +14,52 @@ import { selectEnabledNetworksAsCaipChainIds } from '../../../selectors/multicha import { selectRequiredTransactionHashes } from '../../../selectors/transactionController'; import type { ActivityListFilter } from './helpers'; +const knownApiMessages = ['networks param contains no supported chains']; + +type TransactionsQueryOptions = ReturnType< + typeof apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions +>; +type TransactionsQueryFunction = Extract< + NonNullable, + (...args: never[]) => unknown +>; + +function isKnownApiResponseError(error: unknown) { + if (!(error instanceof HttpError) || error.status !== 400) { + return false; + } + + const errorBodyMessage = getErrorBodyMessage(error.body); + + return Boolean( + errorBodyMessage && + knownApiMessages.some((message) => errorBodyMessage.includes(message)), + ); +} + +function withKnownApiResponse(queryFn: TransactionsQueryOptions['queryFn']) { + if (typeof queryFn !== 'function') { + return queryFn; + } + + return async (context: Parameters[0]) => { + try { + return await queryFn(context); + } catch (error) { + if (isKnownApiResponseError(error)) { + return { + data: [], + pageInfo: { + count: 0, + hasNextPage: false, + }, + }; + } + throw error; + } + }; +} + function getTransactionApiLanguage(locale: string) { return locale.split('-')[0]; } @@ -68,9 +116,10 @@ export function useTransactionsQuery(filter?: ActivityListFilter) { lang, }); - // @ts-expect-error apiClient returns v5 types, repo still in v4 return useInfiniteQuery({ ...queryOptions, + // @ts-expect-error apiClient returns v5 types, repo still in v4 + queryFn: withKnownApiResponse(queryOptions.queryFn), select: selectFn, enabled: Boolean(useExternalServices) && @@ -116,9 +165,10 @@ export function usePrefetchTransactions() { } queryClient - // @ts-expect-error apiClient returns v5 types, repo still in v4 .prefetchInfiniteQuery({ ...queryOptions, + // @ts-expect-error apiClient returns v5 types, repo still in v4 + queryFn: withKnownApiResponse(queryOptions.queryFn), retry: false, staleTime: 5 * MINUTE, })