diff --git a/src/constants/tokenLists.ts b/src/constants/tokenLists.ts index eef5ed10..12475c5b 100644 --- a/src/constants/tokenLists.ts +++ b/src/constants/tokenLists.ts @@ -2,9 +2,8 @@ * @dev Here you can add the list of tokens you want to use in the app * The list follow the standard from: https://tokenlists.org/ * - * Token list must complain with the Schema defined in /src/token.ts + * Token list must comply with the Schema defined in /src/token.ts */ export const tokenLists = { - '1INCH': 'https://ipfs.io/ipns/tokens.1inch.eth', COINGECKO: 'https://tokens.coingecko.com/uniswap/all.json', } as const diff --git a/src/hooks/useTokenLists.test.ts b/src/hooks/useTokenLists.test.ts index bb8ddc0f..efbb40a3 100644 --- a/src/hooks/useTokenLists.test.ts +++ b/src/hooks/useTokenLists.test.ts @@ -36,7 +36,10 @@ vi.mock('@tanstack/react-query', async (importActual) => { }) import * as tanstackQuery from '@tanstack/react-query' -import { useTokenLists } from './useTokenLists' +import { fetchTokenList, useTokenLists } from './useTokenLists' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) const mockToken1: Token = { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', @@ -73,6 +76,87 @@ beforeEach(() => { }) }) +describe('fetchTokenList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns empty token list on HTTP error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 504, + statusText: 'Gateway Timeout', + }) + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await fetchTokenList('https://example.com/tokens.json') + + expect(result.tokens).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Token list fetch failed')) + warnSpy.mockRestore() + }) + + it('returns empty token list on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')) + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await fetchTokenList('https://example.com/tokens.json') + + expect(result.tokens).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Token list fetch failed'), + 'Network error', + ) + warnSpy.mockRestore() + }) + + it('returns empty token list on invalid JSON schema', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ error: 'not a token list' }), + }) + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await fetchTokenList('https://example.com/tokens.json') + + expect(result.tokens).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid schema')) + warnSpy.mockRestore() + }) + + it('returns token list on valid response', async () => { + const validTokenList = { + name: 'Test', + timestamp: '2026-01-01', + version: { major: 1, minor: 0, patch: 0 }, + tokens: [{ symbol: 'ETH', name: 'Ether', address: '0x0', chainId: 1, decimals: 18 }], + } + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(validTokenList), + }) + + const result = await fetchTokenList('https://example.com/tokens.json') + + expect(result.tokens).toHaveLength(1) + expect(result.tokens[0].symbol).toBe('ETH') + }) + + it('returns empty token list when tokens field is not an array', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ tokens: 'not an array' }), + }) + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await fetchTokenList('https://example.com/tokens.json') + + expect(result.tokens).toEqual([]) + warnSpy.mockRestore() + }) +}) + describe('useTokenLists', () => { it('returns tokens and tokensByChainId', () => { vi.mocked(tanstackQuery.useSuspenseQueries).mockReturnValueOnce( diff --git a/src/hooks/useTokenLists.ts b/src/hooks/useTokenLists.ts index 79009d9b..73b48463 100644 --- a/src/hooks/useTokenLists.ts +++ b/src/hooks/useTokenLists.ts @@ -63,8 +63,8 @@ export const useTokenLists = (): TokensMap => { queries: tokenListUrls.map>((url) => ({ queryKey: ['tokens-list', url], queryFn: () => fetchTokenList(url), - staleTime: Number.POSITIVE_INFINITY, - gcTime: Number.POSITIVE_INFINITY, + staleTime: 60 * 60 * 1000, + gcTime: 60 * 60 * 1000, })), combine: combineTokenLists, }) @@ -145,26 +145,48 @@ function combineTokenLists(results: Array>): T return tokensMap } +const emptyTokenList: TokenList = { + name: '', + timestamp: '', + version: { major: 0, minor: 0, patch: 0 }, + tokens: [], +} + /** - * A wrapper around fetch, to return the parsed JSON or throw an error if something goes wrong + * Fetches a token list from a URL. Returns an empty token list on failure + * instead of throwing, so one broken source doesn't block the entire app. * - * @param url - a link to a list of tokens or 'default' to use the list added as a dependency to the project - * @returns {Promise} a token list + * @param url - a link to a list of tokens or 'default' to use the bundled list + * @returns a token list (empty on failure) */ -async function fetchTokenList(url: string): Promise { +export async function fetchTokenList(url: string): Promise { if (url === 'default') { return defaultTokens as TokenList } - const result = await fetch(url) + try { + const result = await fetch(url) + + if (!result.ok) { + console.warn(`Token list fetch failed for ${url}: HTTP ${result.status} ${result.statusText}`) + return emptyTokenList + } - if (!result.ok) { - throw new Error( - `Something went wrong. HTTP status code: ${result.status}. Status Message: ${result.statusText}`, + const data = await result.json() + + if (!data || typeof data !== 'object' || !Array.isArray(data.tokens)) { + console.warn(`Token list fetch for ${url} returned invalid schema; expected a tokens array.`) + return emptyTokenList + } + + return data as TokenList + } catch (error) { + console.warn( + `Token list fetch failed for ${url}:`, + error instanceof Error ? error.message : error, ) + return emptyTokenList } - - return result.json() } /**