Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
20 changes: 12 additions & 8 deletions apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down Expand Up @@ -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"
Expand All @@ -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</0> on how to add custom tokens."
msgstr "<0>Read our guide</0> on how to add custom tokens."
#~ msgid "<0>Read our guide</0> on how to add custom tokens."
#~ msgstr "<0>Read our guide</0> on how to add custom tokens."

#: apps/cowswap-frontend/src/modules/hooksStore/containers/TenderlySimulate/index.tsx
msgid "Retry"
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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."

Expand Down Expand Up @@ -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</0> on how to add custom tokens."
msgstr "Can't find your token on the list? <0>Read our guide</0> 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."
Expand Down Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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)
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,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()
Expand All @@ -34,7 +32,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 +64,7 @@ describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => {
} as BalancesState,
],
[balancesUpdateAtom, mockBalancesUpdate],
[bffUnsupportedChainsAtom, new Set<SupportedChainId>()],
])
return <>{children}</>
}
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<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 UnsupportedChainError()

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,
)
})
})
})
68 changes: 51 additions & 17 deletions libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | null
message?: string
}

export interface PersistBalancesFromBffParams {
Expand All @@ -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<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 (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)

Expand All @@ -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
Expand Down Expand Up @@ -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
}
20 changes: 20 additions & 0 deletions libs/balances-and-allowances/src/state/isBffFailedAtom.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -10,3 +13,20 @@ 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 useCallback(
(chainId) => {
setAtom((prev) => {
if (prev.has(chainId)) {
return prev
}
return new Set([...prev, chainId])
})
},
[setAtom]
)
}
Loading