diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index f3c5e98be68..249a4fc4d72 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -311,7 +311,7 @@ msgstr "Easily set and manage your orders in USD" msgid "Click \"Wrap {nativeSymbol}\" to try again." msgstr "Click \"Wrap {nativeSymbol}\" to try again." -#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts msgid "Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists." msgstr "Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists." @@ -840,8 +840,8 @@ msgid "Copied" msgstr "Copied" #: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx -msgid "Can't find your token on the list?" -msgstr "Can't find your token on the list?" +#~ msgid "Can't find your token on the list?" +#~ msgstr "Can't find your token on the list?" #: apps/cowswap-frontend/src/modules/trade/pure/ReceiveAmountTitle/index.tsx msgid "icon" @@ -860,8 +860,8 @@ msgid "Please connect your wallet to one of our supported networks." msgstr "Please connect your wallet to one of our supported networks." #: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx -msgid "<0>Read our guide on how to add custom tokens." -msgstr "<0>Read our guide on how to add custom tokens." +#~ msgid "<0>Read our guide on how to add custom tokens." +#~ msgstr "<0>Read our guide on how to add custom tokens." #: apps/cowswap-frontend/src/modules/hooksStore/containers/TenderlySimulate/index.tsx msgid "Retry" @@ -1166,7 +1166,7 @@ msgstr "Select an {accountProxyLabelString} to check for available refunds {chai msgid "Unsupported wallet" msgstr "Unsupported wallet" -#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts msgid "Expanded results from inactive Token Lists" msgstr "Expanded results from inactive Token Lists" @@ -2961,7 +2961,7 @@ msgstr "Safe confirmed signatures" msgid "Winning solver" msgstr "Winning solver" -#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts msgid "Tokens from external sources." msgstr "Tokens from external sources." @@ -3911,6 +3911,10 @@ msgstr "User rejected approval transaction" msgid "Swap on" msgstr "Swap on" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx +msgid "Can't find your token on the list? <0>Read our guide on how to add custom tokens." +msgstr "Can't find your token on the list? <0>Read our guide on how to add custom tokens." + #: apps/cowswap-frontend/src/modules/trade/pure/ProtocolFeeRow/index.tsx #~ msgid "The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed.<0/><1/>Solver rewards are taken from this fee amount." #~ msgstr "The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed.<0/><1/>Solver rewards are taken from this fee amount." @@ -4225,7 +4229,7 @@ msgstr "Version" msgid "All tokens" msgstr "All tokens" -#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts msgid "Additional Results from External Sources" msgstr "Additional Results from External Sources" diff --git a/libs/balances-and-allowances/src/constants/bff-balances-swr-config.ts b/libs/balances-and-allowances/src/constants/bff-balances-swr-config.ts index 65077cb12de..7afae99ba46 100644 --- a/libs/balances-and-allowances/src/constants/bff-balances-swr-config.ts +++ b/libs/balances-and-allowances/src/constants/bff-balances-swr-config.ts @@ -2,6 +2,7 @@ import ms from 'ms.macro' import { SWRConfiguration } from 'swr' import { BASIC_MULTICALL_SWR_CONFIG } from '../consts' +import { isUnsupportedChainMessage } from '../utils/UnsupportedChainError' let focusLostTimestamp: number | null = null const FOCUS_HIDDEN_DELAY = ms`20s` @@ -46,7 +47,12 @@ export const BFF_BALANCES_SWR_CONFIG: SWRConfiguration = { // Pause only if focus has been lost for more than ${FOCUS_HIDDEN_DELAY} seconds return Date.now() - focusLostTimestamp > FOCUS_HIDDEN_DELAY }, - onErrorRetry: (_: unknown, __key, config, revalidate, { retryCount }) => { + onErrorRetry: (error: unknown, _key, config, revalidate, { retryCount }) => { + // Don't retry if error is "Unsupported chain" + if (error instanceof Error && isUnsupportedChainMessage(error.message)) { + return + } + const timeout = config.errorRetryInterval * Math.pow(2, retryCount - 1) setTimeout(() => revalidate({ retryCount }), timeout) diff --git a/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.test.tsx b/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.test.tsx index 49ed1ebe902..a9cc0b261df 100644 --- a/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.test.tsx +++ b/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.test.tsx @@ -1,11 +1,11 @@ -import { Provider } from 'jotai' +import { Provider, useAtomValue } from 'jotai' import { useHydrateAtoms } from 'jotai/utils' import React, { ReactNode } from 'react' import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' import { PersistentStateByChain } from '@cowprotocol/types' -import { renderHook } from '@testing-library/react' +import { renderHook, waitFor } from '@testing-library/react' import fetchMock from 'jest-fetch-mock' import useSWR from 'swr' @@ -14,16 +14,14 @@ import { PersistBalancesFromBffParams, usePersistBalancesFromBff } from './usePe import { BFF_BALANCES_SWR_CONFIG } from '../constants/bff-balances-swr-config' import { balancesAtom, BalancesState, balancesUpdateAtom } from '../state/balancesAtom' -import * as isBffFailedAtom from '../state/isBffFailedAtom' -import * as bffUtils from '../utils/isBffSupportedNetwork' +import { bffUnsupportedChainsAtom } from '../state/isBffFailedAtom' +import { UnsupportedChainError } from '../utils/UnsupportedChainError' // Enable fetch mocking fetchMock.enableMocks() // Mock modules jest.mock('swr') -jest.mock('../utils/isBffSupportedNetwork') -jest.mock('../state/isBffFailedAtom') // Create mock for useWalletInfo const mockUseWalletInfo = jest.fn() @@ -34,7 +32,6 @@ jest.mock('@cowprotocol/wallet', () => ({ })) describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => { - const mockSetIsBffFailed = jest.fn() const mockWalletInfo = { chainId: SupportedChainId.MAINNET, account: '0x1234567890123456789012345678901234567890', @@ -67,6 +64,7 @@ describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => { } as BalancesState, ], [balancesUpdateAtom, mockBalancesUpdate], + [bffUnsupportedChainsAtom, new Set()], ]) return <>{children} } @@ -82,8 +80,6 @@ describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => { jest.clearAllMocks() fetchMock.resetMocks() mockUseWalletInfo.mockReturnValue(mockWalletInfo) - ;(isBffFailedAtom.useSetIsBffFailed as jest.Mock).mockReturnValue(mockSetIsBffFailed) - ;(bffUtils.isBffSupportedNetwork as jest.Mock).mockReturnValue(true) }) describe('hardcoded SWR config', () => { @@ -189,4 +185,104 @@ describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => { }) }) + describe('unsupported chain handling', () => { + it('should not make requests for unsupported chains', () => { + const mockUseSWR = useSWR as jest.MockedFunction + mockUseSWR.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: false, + isValidating: false, + mutate: jest.fn(), + } as ReturnType) + + const unsupportedChainParams: PersistBalancesFromBffParams = { + ...defaultParams, + chainId: SupportedChainId.SEPOLIA, // Unsupported network + } + + renderHook(() => usePersistBalancesFromBff(unsupportedChainParams), { wrapper }) + + // Should not make SWR call for unsupported network + expect(mockUseSWR).toHaveBeenCalledWith( + null, // Key should be null for unsupported network + expect.any(Function), + BFF_BALANCES_SWR_CONFIG, + ) + }) + + it('should add chain to unsupported list when "Unsupported chain" error occurs', async () => { + const mockUseSWR = useSWR as jest.MockedFunction + const unsupportedChainError = new UnsupportedChainError() + + mockUseSWR.mockReturnValue({ + data: undefined, + error: unsupportedChainError, + isLoading: false, + isValidating: false, + mutate: jest.fn(), + } as ReturnType) + + const useUnsupportedChains = (): Set => { + usePersistBalancesFromBff(defaultParams) + return useAtomValue(bffUnsupportedChainsAtom) + } + + const { result } = renderHook(() => useUnsupportedChains(), { wrapper }) + + // Wait for effect to run and add chain to unsupported list + await waitFor( + () => { + expect(result.current.has(defaultParams.chainId)).toBe(true) + }, + { timeout: 3000 }, + ) + }) + + it('should stop making requests after chain is added to unsupported list', () => { + const mockUseSWR = useSWR as jest.MockedFunction + + const wrapperWithUnsupportedChain = ({ children }: { children: ReactNode }): ReactNode => { + const HydrateAtoms = ({ children }: { children: ReactNode }): ReactNode => { + useHydrateAtoms([ + [ + balancesAtom, + { + isLoading: false, + chainId: SupportedChainId.MAINNET, + values: {}, + fromCache: false, + } as BalancesState, + ], + [balancesUpdateAtom, mockBalancesUpdate], + [bffUnsupportedChainsAtom, new Set([SupportedChainId.MAINNET])], // Chain is in unsupported list + ]) + return <>{children} + } + + return ( + + {children} + + ) + } + + mockUseSWR.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: false, + isValidating: false, + mutate: jest.fn(), + } as ReturnType) + + renderHook(() => usePersistBalancesFromBff(defaultParams), { wrapper: wrapperWithUnsupportedChain }) + + // Should not make SWR call because chain is in unsupported list + expect(mockUseSWR).toHaveBeenCalledWith( + null, // Key should be null + expect.any(Function), + BFF_BALANCES_SWR_CONFIG, + ) + }) + }) }) diff --git a/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.ts b/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.ts index af37a304932..eebe4ebdaea 100644 --- a/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.ts +++ b/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.ts @@ -10,11 +10,17 @@ import useSWR, { SWRConfiguration } from 'swr' import { BFF_BALANCES_SWR_CONFIG } from '../constants/bff-balances-swr-config' import { balancesAtom, BalancesState, balancesUpdateAtom } from '../state/balancesAtom' -import { useSetIsBffFailed } from '../state/isBffFailedAtom' -import { isBffSupportedNetwork } from '../utils/isBffSupportedNetwork' +import { useAddUnsupportedChainId, useSetIsBffFailed } from '../state/isBffFailedAtom' +import { useIsBffSupportedNetwork } from '../utils/isBffSupportedNetwork' +import { + isUnsupportedChainError, + isUnsupportedChainMessage, + UnsupportedChainError, +} from '../utils/UnsupportedChainError' type BalanceResponse = { balances: Record | null + message?: string } export interface PersistBalancesFromBffParams { @@ -25,15 +31,41 @@ export interface PersistBalancesFromBffParams { tokenAddresses: string[] } +function parseErrorResponse(data: unknown, statusText: string): string { + if (typeof data === 'object' && data !== null && 'message' in data) { + return String(data.message) + } + return statusText +} + +async function parseBffResponse(res: Response): Promise { + try { + return await res.json() + } catch { + return { message: res.statusText } + } +} + +function handleBffError(res: Response, data: BalanceResponse | { message?: string }): never { + const errorMessage = parseErrorResponse(data, res.statusText) + + if (isUnsupportedChainMessage(errorMessage)) { + throw new UnsupportedChainError() + } + + throw new Error(`BFF error: ${res.status} ${res.statusText}`) +} + export function usePersistBalancesFromBff(params: PersistBalancesFromBffParams): void { const { account, chainId, invalidateCacheTrigger, tokenAddresses } = params const { chainId: activeChainId, account: connectedAccount } = useWalletInfo() const targetAccount = account ?? connectedAccount const targetChainId = chainId ?? activeChainId - const isSupportedNetwork = isBffSupportedNetwork(targetChainId) + const isSupportedNetwork = useIsBffSupportedNetwork(targetChainId) const setIsBffFailed = useSetIsBffFailed() + const addUnsupportedChainId = useAddUnsupportedChainId() const lastTriggerRef = useRef(invalidateCacheTrigger) @@ -59,8 +91,14 @@ export function usePersistBalancesFromBff(params: PersistBalancesFromBffParams): }, [setBalances, isBalancesLoading, targetChainId, targetAccount]) useEffect(() => { + const hasUnsupportedChainError = isUnsupportedChainError(error) + + if (hasUnsupportedChainError) { + addUnsupportedChainId(targetChainId) + } + setIsBffFailed(!!error) - }, [error, setIsBffFailed]) + }, [error, setIsBffFailed, addUnsupportedChainId, targetChainId]) useEffect(() => { if (!targetAccount || !data || error) return @@ -112,20 +150,16 @@ export async function getBffBalances( const queryParams = skipCache ? '?ignoreCache=true' : '' const fullUrl = url + queryParams - try { - const res = await fetch(fullUrl) - const data: BalanceResponse = await res.json() - - if (!res.ok) { - return Promise.reject(new Error(`BFF error: ${res.status} ${res.statusText}`)) - } + const res = await fetch(fullUrl) + const data = await parseBffResponse(res) - if (!data.balances) { - return null - } + if (!res.ok) { + handleBffError(res, data) + } - return data.balances - } catch (error) { - return Promise.reject(error) + if (!('balances' in data) || !data.balances) { + return null } + + return data.balances } diff --git a/libs/balances-and-allowances/src/state/isBffFailedAtom.ts b/libs/balances-and-allowances/src/state/isBffFailedAtom.ts index cb0192f338b..d90cdc11df6 100644 --- a/libs/balances-and-allowances/src/state/isBffFailedAtom.ts +++ b/libs/balances-and-allowances/src/state/isBffFailedAtom.ts @@ -1,5 +1,8 @@ import { useSetAtom } from 'jotai' import { atom, useAtomValue } from 'jotai/index' +import { useCallback } from 'react' + +import { SupportedChainId } from '@cowprotocol/cow-sdk' export const isBffFailedAtom = atom(false) @@ -10,3 +13,20 @@ export function useIsBffFailed(): boolean { export function useSetIsBffFailed(): (value: boolean) => void { return useSetAtom(isBffFailedAtom) } + +export const bffUnsupportedChainsAtom = atom(new Set()) + +export function useAddUnsupportedChainId(): (chainId: SupportedChainId) => void { + const setAtom = useSetAtom(bffUnsupportedChainsAtom) + return useCallback( + (chainId) => { + setAtom((prev) => { + if (prev.has(chainId)) { + return prev + } + return new Set([...prev, chainId]) + }) + }, + [setAtom] + ) +} diff --git a/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx b/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx index 01848130ed1..216f355955b 100644 --- a/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx +++ b/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx @@ -16,6 +16,7 @@ import { BASIC_MULTICALL_SWR_CONFIG } from '../consts' import { useNativeTokenBalance } from '../hooks/useNativeTokenBalance' import { useSwrConfigWithPauseForNetwork } from '../hooks/useSwrConfigWithPauseForNetwork' import { useUpdateTokenBalance } from '../hooks/useUpdateTokenBalance' +import { useIsBffSupportedNetwork } from '../utils/isBffSupportedNetwork' // A small gap between balances and allowances refresh intervals is needed to avoid high load to the node at the same time const RPC_BALANCES_SWR_CONFIG: SWRConfiguration = { ...BASIC_MULTICALL_SWR_CONFIG, refreshInterval: ms`31s` } @@ -40,6 +41,7 @@ export function BalancesAndAllowancesUpdater({ isBffEnabled, }: BalancesAndAllowancesUpdaterProps): ReactNode { const updateTokenBalance = useUpdateTokenBalance() + const isBffSupported = useIsBffSupportedNetwork(chainId) const allTokens = useAllActiveTokens() const { data: nativeTokenBalance } = useNativeTokenBalance(account, chainId) @@ -71,7 +73,7 @@ export function BalancesAndAllowancesUpdater({ return ( <> - {isBffEnabled && ( + {isBffEnabled && isBffSupported && (