Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
Expand Up @@ -46,7 +46,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 && error.message.toLowerCase().includes('unsupported chain')) {
Copy link
Collaborator

@shoom3301 shoom3301 Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should isUnsupportedChainMessage be used here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, fixed

return
}

const timeout = config.errorRetryInterval * Math.pow(2, retryCount - 1)

setTimeout(() => revalidate({ retryCount }), timeout)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -14,16 +14,13 @@ 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'

// 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()
Expand All @@ -34,7 +31,6 @@ jest.mock('@cowprotocol/wallet', () => ({
}))

describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => {
const mockSetIsBffFailed = jest.fn()
const mockWalletInfo = {
chainId: SupportedChainId.MAINNET,
account: '0x1234567890123456789012345678901234567890',
Expand Down Expand Up @@ -67,6 +63,7 @@ describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => {
} as BalancesState,
],
[balancesUpdateAtom, mockBalancesUpdate],
[bffUnsupportedChainsAtom, new Set<SupportedChainId>()],
])
return <>{children}</>
}
Expand All @@ -82,8 +79,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', () => {
Expand Down Expand Up @@ -189,4 +184,104 @@ describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => {
})
})

describe('unsupported chain handling', () => {
it('should not make requests for unsupported chains', () => {
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>
mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: false,
isValidating: false,
mutate: jest.fn(),
} as ReturnType<typeof useSWR>)

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<typeof useSWR>
const unsupportedChainError = new Error('Unsupported chain')

mockUseSWR.mockReturnValue({
data: undefined,
error: unsupportedChainError,
isLoading: false,
isValidating: false,
mutate: jest.fn(),
} as ReturnType<typeof useSWR>)

const useUnsupportedChains = (): Set<SupportedChainId> => {
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<typeof useSWR>

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 (
<Provider>
<HydrateAtoms>{children}</HydrateAtoms>
</Provider>
)
}

mockUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: false,
isValidating: false,
mutate: jest.fn(),
} as ReturnType<typeof useSWR>)

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,
)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ 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 { useSetIsBffFailed, useAddUnsupportedChainId } from '../state/isBffFailedAtom'
import { useIsBffSupportedNetwork } from '../utils/isBffSupportedNetwork'

type BalanceResponse = {
balances: Record<string, string> | null
message?: string
}

export interface PersistBalancesFromBffParams {
Expand All @@ -25,15 +26,45 @@ export interface PersistBalancesFromBffParams {
tokenAddresses: string[]
}

function isUnsupportedChainError(errorMessage: string): boolean {
return errorMessage.toLowerCase().includes('unsupported chain')
}

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<BalanceResponse | { message?: string }> {
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 (isUnsupportedChainError(errorMessage)) {
throw new Error('Unsupported chain')
}

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)

Expand All @@ -59,8 +90,15 @@ export function usePersistBalancesFromBff(params: PersistBalancesFromBffParams):
}, [setBalances, isBalancesLoading, targetChainId, targetAccount])

useEffect(() => {
const hasUnsupportedChainError = error instanceof Error &&
isUnsupportedChainError(error.message)

if (hasUnsupportedChainError) {
addUnsupportedChainId(targetChainId)
}

setIsBffFailed(!!error)
}, [error, setIsBffFailed])
}, [error, setIsBffFailed, addUnsupportedChainId, targetChainId])

useEffect(() => {
if (!targetAccount || !data || error) return
Expand Down Expand Up @@ -114,18 +152,21 @@ export async function getBffBalances(

try {
const res = await fetch(fullUrl)
const data: BalanceResponse = await res.json()
const data = await parseBffResponse(res)

if (!res.ok) {
return Promise.reject(new Error(`BFF error: ${res.status} ${res.statusText}`))
handleBffError(res, data)
}

if (!data.balances) {
if (!('balances' in data) || !data.balances) {
return null
}

return data.balances
} catch (error) {
return Promise.reject(error)
if (error instanceof Error && isUnsupportedChainError(error.message)) {
throw new Error('Unsupported chain')
}
throw error
}
}
16 changes: 16 additions & 0 deletions libs/balances-and-allowances/src/state/isBffFailedAtom.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useSetAtom } from 'jotai'
import { atom, useAtomValue } from 'jotai/index'

import { SupportedChainId } from '@cowprotocol/cow-sdk'

export const isBffFailedAtom = atom(false)

export function useIsBffFailed(): boolean {
Expand All @@ -10,3 +12,17 @@ export function useIsBffFailed(): boolean {
export function useSetIsBffFailed(): (value: boolean) => void {
return useSetAtom(isBffFailedAtom)
}

export const bffUnsupportedChainsAtom = atom(new Set<SupportedChainId>())

export function useAddUnsupportedChainId(): (chainId: SupportedChainId) => void {
const setAtom = useSetAtom(bffUnsupportedChainsAtom)
return (chainId) => {
setAtom((prev) => {
if (prev.has(chainId)) {
return prev
}
return new Set([...prev, chainId])
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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` }
Expand All @@ -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)
Expand Down Expand Up @@ -71,7 +73,7 @@ export function BalancesAndAllowancesUpdater({

return (
<>
{isBffEnabled && (
{isBffEnabled && isBffSupported && (
<BalancesBffUpdater
account={account}
chainId={chainId}
Expand Down
11 changes: 10 additions & 1 deletion libs/balances-and-allowances/src/utils/isBffSupportedNetwork.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { useAtomValue } from 'jotai'

import { SupportedChainId } from '@cowprotocol/cow-sdk'

import { bffUnsupportedChainsAtom } from '../state/isBffFailedAtom'

// TODO: check before Plasma launch. Currently unsupported on 2025/10/20
const UNSUPPORTED_BFF_NETWORKS = [SupportedChainId.LENS, SupportedChainId.SEPOLIA, SupportedChainId.PLASMA]
const UNSUPPORTED_BFF_NETWORKS = [SupportedChainId.PLASMA, SupportedChainId.SEPOLIA]

export function isBffSupportedNetwork(chainId: SupportedChainId): boolean {
return !UNSUPPORTED_BFF_NETWORKS.includes(chainId)
}

export function useIsBffSupportedNetwork(chainId: SupportedChainId): boolean {
const unsupportedChains = useAtomValue(bffUnsupportedChainsAtom)
return isBffSupportedNetwork(chainId) && !unsupportedChains.has(chainId)
}