diff --git a/apps/cowswap-frontend/src/modules/balancesAndAllowances/hooks/useOrdersFilledEventsTrigger.ts b/apps/cowswap-frontend/src/modules/balancesAndAllowances/hooks/useOrdersFilledEventsTrigger.ts deleted file mode 100644 index 56212c49546..00000000000 --- a/apps/cowswap-frontend/src/modules/balancesAndAllowances/hooks/useOrdersFilledEventsTrigger.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useEffect, useState } from 'react' - -import { useDebounce } from '@cowprotocol/common-hooks' -import { CowEventListener, CowWidgetEventPayloadMap, CowWidgetEvents } from '@cowprotocol/events' - -import ms from 'ms.macro' -import { WIDGET_EVENT_EMITTER } from 'widgetEventEmitter' - -const DEBOUNCE_FOR_PENDING_ORDERS_MS = ms`1s` - -type OrderFilledListener = CowEventListener -type BridgingSuccessListener = CowEventListener - -/** - * Invalidate cache trigger that only updates when the number of pending orders decreases - * This useful to force a refresh when orders are being fulfilled or bridging is completed - * */ -export function useOrdersFilledEventsTrigger(): number { - const [triggerValue, setTriggerValue] = useState(0) - - useEffect(() => { - const incrementTrigger = (): void => { - setTriggerValue((prev) => prev + 1) - } - - const onFulfilledListener: OrderFilledListener = { - event: CowWidgetEvents.ON_FULFILLED_ORDER, - handler: incrementTrigger, - } - - const onBridgingListener: BridgingSuccessListener = { - event: CowWidgetEvents.ON_BRIDGING_SUCCESS, - handler: incrementTrigger, - } - - WIDGET_EVENT_EMITTER.on(onFulfilledListener) - WIDGET_EVENT_EMITTER.on(onBridgingListener) - - return (): void => { - WIDGET_EVENT_EMITTER.off(onFulfilledListener) - WIDGET_EVENT_EMITTER.off(onBridgingListener) - } - }, []) - - return useDebounce(triggerValue, DEBOUNCE_FOR_PENDING_ORDERS_MS) -} diff --git a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx index fab0d6d0513..2171c1a3559 100644 --- a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx @@ -2,12 +2,10 @@ import { ReactNode, useEffect, useMemo, useState } from 'react' import { BalancesAndAllowancesUpdater, - isBffSupportedNetwork, PRIORITY_TOKENS_REFRESH_INTERVAL, PriorityTokensUpdater, - useIsBffFailed, + useIsSseFailed, } from '@cowprotocol/balances-and-allowances' -import { useFeatureFlags } from '@cowprotocol/common-hooks' import { useWalletInfo } from '@cowprotocol/wallet' import { useBalancesContext } from 'entities/balancesContext/useBalancesContext' @@ -15,22 +13,6 @@ import { useBalancesContext } from 'entities/balancesContext/useBalancesContext' import { useSourceChainId } from 'modules/tokensList' import { usePriorityTokenAddresses } from 'modules/trade' -import { useOrdersFilledEventsTrigger } from '../hooks/useOrdersFilledEventsTrigger' - -function shouldApplyBffBalances(account: string | undefined, percentage: number | boolean | undefined): boolean { - // Early exit for 100%, meaning should be enabled for everyone - if (percentage === 100) { - return true - } - - // Falsy conditions - if (typeof percentage !== 'number' || !account || percentage < 0 || percentage > 100) { - return false - } - - return BigInt(account) % 100n < percentage -} - export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const sourceChainId = useSourceChainId().chainId const { account } = useWalletInfo() @@ -44,6 +26,7 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const priorityTokenCount = priorityTokenAddressesAsArray.length const [skipFirstPriorityUpdate, setSkipFirstPriorityUpdate] = useState(true) + const isSseFailed = useIsSseFailed() /** * Reset skipFirstPriorityUpdate on every network change @@ -67,32 +50,20 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { } }, [account, priorityTokenCount]) - const { bffBalanceEnabledPercentage } = useFeatureFlags() - const isBffFailed = useIsBffFailed() - const isBffSupportNetwork = isBffSupportedNetwork(sourceChainId) - const isBffEnabled = shouldApplyBffBalances(account, bffBalanceEnabledPercentage) - const isBffSwitchedOn = isBffEnabled && !isBffFailed && isBffSupportNetwork - const invalidateCacheTrigger = useOrdersFilledEventsTrigger() - return ( <> - {!isBffSwitchedOn ? ( + {/* Priority tokens use RPC when SSE fails */} + {isSseFailed && ( - ) : null} + )} ) 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 deleted file mode 100644 index 65077cb12de..00000000000 --- a/libs/balances-and-allowances/src/constants/bff-balances-swr-config.ts +++ /dev/null @@ -1,54 +0,0 @@ -import ms from 'ms.macro' -import { SWRConfiguration } from 'swr' - -import { BASIC_MULTICALL_SWR_CONFIG } from '../consts' - -let focusLostTimestamp: number | null = null -const FOCUS_HIDDEN_DELAY = ms`20s` - -function initializeFocusListeners(): void { - if (typeof document === 'undefined') { - return - } - - document.addEventListener('visibilitychange', () => { - focusLostTimestamp = document.hidden ? Date.now() : null - }) - - window.addEventListener('blur', () => { - focusLostTimestamp = Date.now() - }) - - window.addEventListener('focus', () => { - focusLostTimestamp = null - }) -} - -export const BFF_BALANCES_SWR_CONFIG: SWRConfiguration = { - ...BASIC_MULTICALL_SWR_CONFIG, - revalidateIfStale: true, - refreshInterval: ms`8s`, - errorRetryCount: 3, - errorRetryInterval: ms`30s`, - isPaused() { - initializeFocusListeners() - - if (document.hasFocus()) { - focusLostTimestamp = null - return false - } - - if (!focusLostTimestamp) { - focusLostTimestamp = Date.now() - return false - } - - // 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 }) => { - 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 deleted file mode 100644 index 49ed1ebe902..00000000000 --- a/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { Provider } 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 fetchMock from 'jest-fetch-mock' -import useSWR from 'swr' - -// Import the function to test after all mocks are set up -import { PersistBalancesFromBffParams, usePersistBalancesFromBff } from './usePersistBalancesFromBff' - -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' - -// 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() - -// Mock the wallet module -jest.mock('@cowprotocol/wallet', () => ({ - useWalletInfo: () => mockUseWalletInfo(), -})) - -describe('usePersistBalancesFromBff - invalidateCacheTrigger', () => { - const mockSetIsBffFailed = jest.fn() - const mockWalletInfo = { - chainId: SupportedChainId.MAINNET, - account: '0x1234567890123456789012345678901234567890', - } - - const defaultParams: PersistBalancesFromBffParams = { - account: '0x1234567890123456789012345678901234567890', - chainId: SupportedChainId.MAINNET, - invalidateCacheTrigger: 0, - tokenAddresses: ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'], - } - - const mockBalancesData = { - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': '1000000', - } - - // Create complete mock data for balancesUpdateAtom - const mockBalancesUpdate: PersistentStateByChain> = mapSupportedNetworks({}) - - const wrapper = ({ children }: { children: ReactNode }): ReactNode => { - const HydrateAtoms = ({ children }: { children: ReactNode }): ReactNode => { - useHydrateAtoms([ - [ - balancesAtom, - { - isLoading: false, - chainId: SupportedChainId.MAINNET, - values: {}, - fromCache: false, - } as BalancesState, - ], - [balancesUpdateAtom, mockBalancesUpdate], - ]) - return <>{children} - } - - return ( - - {children} - - ) - } - - beforeEach(() => { - 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', () => { - it('should use hardcoded BFF_BALANCES_SWR_CONFIG', () => { - const mockUseSWR = useSWR as jest.MockedFunction - mockUseSWR.mockReturnValue({ - data: mockBalancesData, - error: undefined, - isLoading: false, - isValidating: false, - mutate: jest.fn(), - } as ReturnType) - - renderHook(() => usePersistBalancesFromBff(defaultParams), { wrapper }) - - expect(mockUseSWR).toHaveBeenCalledWith( - expect.any(Array), - expect.any(Function), - BFF_BALANCES_SWR_CONFIG, // Verify hardcoded config is used - ) - }) - }) - - describe('invalidateCacheTrigger parameter', () => { - it('should trigger cache invalidation when value changes', async () => { - const mockUseSWR = useSWR as jest.MockedFunction - - mockUseSWR.mockImplementation((key, fetcher, _config) => { - if (key && fetcher) { - Promise.resolve(fetcher(key as [string, SupportedChainId])).catch(() => {}) - } - return { - data: mockBalancesData, - error: undefined, - isLoading: false, - isValidating: false, - mutate: jest.fn(), - } as ReturnType - }) - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ balances: mockBalancesData }), - } as Response) - - const { rerender } = renderHook( - ({ trigger }: { trigger: number }) => - usePersistBalancesFromBff({ ...defaultParams, invalidateCacheTrigger: trigger }), - { - wrapper, - initialProps: { trigger: 0 }, - }, - ) - - // Initial call with trigger = 0 - expect(mockUseSWR).toHaveBeenCalledWith( - [defaultParams.account, defaultParams.chainId, 0, 'bff-balances'], - expect.any(Function), - BFF_BALANCES_SWR_CONFIG, - ) - - // Change trigger to force cache invalidation - rerender({ trigger: 1 }) - - expect(mockUseSWR).toHaveBeenCalledWith( - [defaultParams.account, defaultParams.chainId, 1, 'bff-balances'], - expect.any(Function), - BFF_BALANCES_SWR_CONFIG, - ) - - // Change trigger again - rerender({ trigger: 5 }) - - expect(mockUseSWR).toHaveBeenCalledWith( - [defaultParams.account, defaultParams.chainId, 5, 'bff-balances'], - expect.any(Function), - BFF_BALANCES_SWR_CONFIG, - ) - }) - - it('should handle undefined invalidateCacheTrigger', () => { - const mockUseSWR = useSWR as jest.MockedFunction - mockUseSWR.mockReturnValue({ - data: mockBalancesData, - error: undefined, - isLoading: false, - isValidating: false, - mutate: jest.fn(), - } as ReturnType) - - const paramsWithoutTrigger: Omit = { - account: defaultParams.account, - chainId: defaultParams.chainId, - tokenAddresses: defaultParams.tokenAddresses, - } - - renderHook(() => usePersistBalancesFromBff(paramsWithoutTrigger as PersistBalancesFromBffParams), { wrapper }) - - expect(mockUseSWR).toHaveBeenCalledWith( - [defaultParams.account, defaultParams.chainId, undefined, 'bff-balances'], - 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 deleted file mode 100644 index af37a304932..00000000000 --- a/libs/balances-and-allowances/src/hooks/usePersistBalancesFromBff.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useSetAtom } from 'jotai' -import { useEffect, useRef } from 'react' - -import { BFF_BASE_URL } from '@cowprotocol/common-const' -import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { useWalletInfo } from '@cowprotocol/wallet' -import { BigNumber } from '@ethersproject/bignumber' - -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' - -type BalanceResponse = { - balances: Record | null -} - -export interface PersistBalancesFromBffParams { - account?: string - chainId: SupportedChainId - balancesSwrConfig?: SWRConfiguration - invalidateCacheTrigger?: number - tokenAddresses: string[] -} - -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 setIsBffFailed = useSetIsBffFailed() - - const lastTriggerRef = useRef(invalidateCacheTrigger) - - const { - isLoading: isBalancesLoading, - data, - error, - } = useSWR( - targetAccount && isSupportedNetwork ? [targetAccount, targetChainId, invalidateCacheTrigger, 'bff-balances'] : null, - ([walletAddress, chainId]) => { - const skipCache = lastTriggerRef.current !== invalidateCacheTrigger - lastTriggerRef.current = invalidateCacheTrigger - return getBffBalances(walletAddress, chainId, skipCache) - }, - BFF_BALANCES_SWR_CONFIG, - ) - - const setBalances = useSetAtom(balancesAtom) - const setBalancesUpdate = useSetAtom(balancesUpdateAtom) - - useEffect(() => { - setBalances((state) => ({ ...state, isLoading: isBalancesLoading, chainId: targetChainId })) - }, [setBalances, isBalancesLoading, targetChainId, targetAccount]) - - useEffect(() => { - setIsBffFailed(!!error) - }, [error, setIsBffFailed]) - - useEffect(() => { - if (!targetAccount || !data || error) return - - const balancesState = tokenAddresses.reduce((acc, address) => { - address = address.toLowerCase() - const balance = data[address] || '0' - acc[address] = BigNumber.from(balance) - return acc - }, {}) - - setBalances((state) => { - return { - ...state, - chainId: targetChainId, - fromCache: false, - values: balancesState, - isLoading: false, - } - }) - - setBalancesUpdate((state) => ({ - ...state, - [targetChainId]: { - ...state[targetChainId], - [targetAccount.toLowerCase()]: Date.now(), - }, - })) - }, [ - targetChainId, - account, - data, - setBalances, - setBalancesUpdate, - error, - tokenAddresses, - isBalancesLoading, - chainId, - targetAccount, - ]) -} - -export async function getBffBalances( - address: string, - chainId: SupportedChainId, - skipCache = false, -): Promise | null> { - const url = `${BFF_BASE_URL}/${chainId}/address/${address}/balances` - 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}`)) - } - - if (!data.balances) { - return null - } - - return data.balances - } catch (error) { - return Promise.reject(error) - } -} diff --git a/libs/balances-and-allowances/src/hooks/useSseBalances.ts b/libs/balances-and-allowances/src/hooks/useSseBalances.ts new file mode 100644 index 00000000000..58ec7900ef8 --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useSseBalances.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { BALANCES_SSE_URL } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +const RECONNECT_DELAY_MS = 3000 +const MAX_RECONNECT_ATTEMPTS = 5 + +export interface SseBalancesState { + isConnected: boolean + isLoading: boolean + error: Error | null +} + +export interface UseSseBalancesParams { + account: string | undefined + chainId: SupportedChainId + enabled: boolean + tokensListsUrls: string[] + customTokens?: string[] + onAllBalances: (balances: Record) => void + onBalanceUpdate: (address: string, balance: string) => void + onError?: (error: Error) => void +} + +const INITIAL_STATE: SseBalancesState = { isConnected: false, isLoading: false, error: null } + +async function createSession( + chainId: SupportedChainId, + account: string, + tokensListsUrls: string[], + customTokens?: string[], +): Promise { + const url = `${BALANCES_SSE_URL}/${chainId}/sessions/${account}` + console.debug('[SSE] Creating session:', url) + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokensListsUrls, customTokens }), + }) + + if (!response.ok) { + throw new Error(`Session creation failed: ${response.status}`) + } + console.debug('[SSE] Session created successfully') +} + +interface EventHandlers { + onAllBalances: (balances: Record) => void + onBalanceUpdate: (address: string, balance: string) => void + onError?: (error: Error) => void + onOpen: () => void + onClose: () => void +} + +function setupEventSource(url: string, handlers: EventHandlers): EventSource { + const { onAllBalances, onBalanceUpdate, onError, onOpen, onClose } = handlers + console.debug('[SSE] Connecting to:', url) + const es = new EventSource(url) + + es.onopen = (): void => { + console.debug('[SSE] Connection opened') + onOpen() + } + + es.addEventListener('all_balances', (e: MessageEvent): void => { + try { + const { balances } = JSON.parse(e.data) + if (balances && Object.keys(balances).length > 0) onAllBalances(balances) + } catch { + /* ignore */ + } + }) + + es.addEventListener('balance_update', (e: MessageEvent): void => { + try { + const { address, balance } = JSON.parse(e.data) + if (address && balance != null) onBalanceUpdate(address.toLowerCase(), balance) + } catch { + /* ignore */ + } + }) + + es.addEventListener('error', (e: MessageEvent): void => { + try { + const { message, code } = JSON.parse(e.data) + onError?.(new Error(`SSE Error ${code}: ${message}`)) + } catch { + /* ignore */ + } + }) + + es.onerror = (event): void => { + console.debug('[SSE] Error event, readyState:', es.readyState, event) + if (es.readyState === EventSource.CLOSED) { + console.debug('[SSE] Connection closed') + onClose() + } + } + + return es +} + +export function useSseBalances(params: UseSseBalancesParams): SseBalancesState { + const { + account, + chainId, + enabled, + tokensListsUrls, + customTokens = [], + onAllBalances, + onBalanceUpdate, + onError, + } = params + + const [state, setState] = useState(INITIAL_STATE) + const esRef = useRef(null) + const attemptsRef = useRef(0) + const timeoutRef = useRef>(undefined) + + const cleanup = useCallback((): void => { + esRef.current?.close() + esRef.current = null + clearTimeout(timeoutRef.current) + }, []) + + useEffect(() => { + if (!enabled || !account || tokensListsUrls.length === 0) { + console.debug('[SSE] Skipping connection:', { + enabled, + account: !!account, + tokensListsUrls: tokensListsUrls.length, + }) + cleanup() + setState(INITIAL_STATE) + return cleanup + } + + let cancelled = false + + const connect = async (): Promise => { + if (cancelled) return + cleanup() + setState((s) => ({ ...s, isLoading: true, error: null })) + + try { + await createSession(chainId, account, tokensListsUrls, customTokens) + } catch (e) { + const error = e instanceof Error ? e : new Error('Session failed') + console.debug('[SSE] Session creation failed:', error.message) + setState({ ...INITIAL_STATE, error }) + onError?.(error) + return + } + + if (cancelled) return + + esRef.current = setupEventSource(`${BALANCES_SSE_URL}/sse/${chainId}/balances/${account}`, { + onAllBalances, + onBalanceUpdate, + onError, + onOpen: (): void => { + attemptsRef.current = 0 + setState({ isConnected: true, isLoading: false, error: null }) + }, + onClose: (): void => { + setState((s) => ({ ...s, isConnected: false })) + if (attemptsRef.current < MAX_RECONNECT_ATTEMPTS) { + attemptsRef.current++ + timeoutRef.current = setTimeout(() => void connect(), RECONNECT_DELAY_MS * 2 ** (attemptsRef.current - 1)) + } else { + const err = new Error('SSE: Max reconnect attempts') + setState((s) => ({ ...s, error: err })) + onError?.(err) + } + }, + }) + } + + void connect() + return (): void => { + cancelled = true + cleanup() + } + }, [account, chainId, enabled, tokensListsUrls, customTokens, cleanup, onAllBalances, onBalanceUpdate, onError]) + + return state +} diff --git a/libs/balances-and-allowances/src/index.ts b/libs/balances-and-allowances/src/index.ts index 684441ecaec..dcdc0d4428a 100644 --- a/libs/balances-and-allowances/src/index.ts +++ b/libs/balances-and-allowances/src/index.ts @@ -1,6 +1,8 @@ -// Updater +// Updaters export { BalancesAndAllowancesUpdater } from './updaters/BalancesAndAllowancesUpdater' export { PriorityTokensUpdater, PRIORITY_TOKENS_REFRESH_INTERVAL } from './updaters/PriorityTokensUpdater' +export { BalancesRpcCallUpdater } from './updaters/BalancesRpcCallUpdater' +export { BalancesSseUpdater } from './updaters/BalancesSseUpdater' // Hooks export { useTokensBalances } from './hooks/useTokensBalances' @@ -14,15 +16,16 @@ export { useUpdateTokenBalance } from './hooks/useUpdateTokenBalance' export { useTokenAllowances } from './hooks/useTokenAllowances' export { useBalancesAndAllowances } from './hooks/useBalancesAndAllowances' export { useTradeSpenderAddress } from './hooks/useTradeSpenderAddress' -export { useIsBffFailed } from './state/isBffFailedAtom' -export { BalancesBffUpdater } from './updaters/BalancesBffUpdater' -export { BalancesRpcCallUpdater } from './updaters/BalancesRpcCallUpdater' -export type { BalancesAndAllowances } from './types/balances-and-allowances' -export * from './utils/isBffSupportedNetwork' +export { useSseBalances } from './hooks/useSseBalances' + +// State hooks +export { useIsSseFailed } from './state/isSseFailedAtom' // Types +export type { BalancesAndAllowances } from './types/balances-and-allowances' export type { BalancesState } from './state/balancesAtom' export type { AllowancesState } from './hooks/useTokenAllowances' +export type { SseBalancesState, UseSseBalancesParams } from './hooks/useSseBalances' // Consts export { DEFAULT_BALANCES_STATE } from './state/balancesAtom' diff --git a/libs/balances-and-allowances/src/state/isBffFailedAtom.ts b/libs/balances-and-allowances/src/state/isBffFailedAtom.ts deleted file mode 100644 index cb0192f338b..00000000000 --- a/libs/balances-and-allowances/src/state/isBffFailedAtom.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useSetAtom } from 'jotai' -import { atom, useAtomValue } from 'jotai/index' - -export const isBffFailedAtom = atom(false) - -export function useIsBffFailed(): boolean { - return useAtomValue(isBffFailedAtom) -} - -export function useSetIsBffFailed(): (value: boolean) => void { - return useSetAtom(isBffFailedAtom) -} diff --git a/libs/balances-and-allowances/src/state/isSseFailedAtom.ts b/libs/balances-and-allowances/src/state/isSseFailedAtom.ts new file mode 100644 index 00000000000..185f356166d --- /dev/null +++ b/libs/balances-and-allowances/src/state/isSseFailedAtom.ts @@ -0,0 +1,12 @@ +import { useSetAtom } from 'jotai' +import { atom, useAtomValue } from 'jotai/index' + +export const isSseFailedAtom = atom(false) + +export function useIsSseFailed(): boolean { + return useAtomValue(isSseFailedAtom) +} + +export function useSetIsSseFailed(): (value: boolean) => void { + return useSetAtom(isSseFailedAtom) +} diff --git a/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx b/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx index 01848130ed1..7b0f69a20f8 100644 --- a/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx +++ b/libs/balances-and-allowances/src/updaters/BalancesAndAllowancesUpdater.tsx @@ -1,21 +1,20 @@ -import { ReactNode, useEffect, useMemo } from 'react' +import { ReactNode, useMemo } from 'react' -import { LpToken, NATIVE_CURRENCIES } from '@cowprotocol/common-const' +import { LpToken } from '@cowprotocol/common-const' import type { SupportedChainId } from '@cowprotocol/cow-sdk' -import { useAllActiveTokens } from '@cowprotocol/tokens' +import { useAllActiveTokens, useListsEnabledState } from '@cowprotocol/tokens' import ms from 'ms.macro' import { SWRConfiguration } from 'swr' -import { BalancesBffUpdater } from './BalancesBffUpdater' import { BalancesCacheUpdater } from './BalancesCacheUpdater' import { BalancesResetUpdater } from './BalancesResetUpdater' import { BalancesRpcCallUpdater } from './BalancesRpcCallUpdater' +import { BalancesSseUpdater } from './BalancesSseUpdater' import { BASIC_MULTICALL_SWR_CONFIG } from '../consts' -import { useNativeTokenBalance } from '../hooks/useNativeTokenBalance' import { useSwrConfigWithPauseForNetwork } from '../hooks/useSwrConfigWithPauseForNetwork' -import { useUpdateTokenBalance } from '../hooks/useUpdateTokenBalance' +import { useIsSseFailed } from '../state/isSseFailedAtom' // 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` } @@ -25,24 +24,17 @@ const EMPTY_TOKENS: string[] = [] export interface BalancesAndAllowancesUpdaterProps { account: string | undefined chainId: SupportedChainId - invalidateCacheTrigger: number excludedTokens: Set - isBffSwitchedOn: boolean - isBffEnabled?: boolean } export function BalancesAndAllowancesUpdater({ account, chainId, - invalidateCacheTrigger, - isBffSwitchedOn, excludedTokens, - isBffEnabled, }: BalancesAndAllowancesUpdaterProps): ReactNode { - const updateTokenBalance = useUpdateTokenBalance() + const isSseFailed = useIsSseFailed() const allTokens = useAllActiveTokens() - const { data: nativeTokenBalance } = useNativeTokenBalance(account, chainId) const tokenAddresses = useMemo(() => { if (allTokens.chainId !== chainId) return EMPTY_TOKENS @@ -55,31 +47,34 @@ export function BalancesAndAllowancesUpdater({ }, []) }, [allTokens, chainId]) - const rpcBalancesSwrConfig = useSwrConfigWithPauseForNetwork(chainId, account, RPC_BALANCES_SWR_CONFIG) - // Add native token balance to the store as well - useEffect(() => { - if (isBffSwitchedOn) return - - const nativeToken = NATIVE_CURRENCIES[chainId] + // Get enabled token list URLs for SSE + const listsEnabledState = useListsEnabledState() + const tokensListsUrls = useMemo(() => { + return Object.entries(listsEnabledState) + .filter(([, isEnabled]) => isEnabled === true) + .map(([url]) => url) + }, [listsEnabledState]) - if (nativeToken && nativeTokenBalance) { - updateTokenBalance(nativeToken.address, nativeTokenBalance) - } - }, [isBffSwitchedOn, nativeTokenBalance, chainId, updateTokenBalance]) + const rpcBalancesSwrConfig = useSwrConfigWithPauseForNetwork(chainId, account, RPC_BALANCES_SWR_CONFIG) - const enableRpcFallback = !isBffSwitchedOn || !isBffEnabled + // Determine which updater to use + const hasSseTokenLists = tokensListsUrls.length > 0 + const useSse = !isSseFailed && hasSseTokenLists return ( <> - {isBffEnabled && ( - )} - {enableRpcFallback && ( + + {/* RPC fallback when SSE fails */} + {isSseFailed && ( )} + diff --git a/libs/balances-and-allowances/src/updaters/BalancesBffUpdater.tsx b/libs/balances-and-allowances/src/updaters/BalancesBffUpdater.tsx deleted file mode 100644 index 7112408559d..00000000000 --- a/libs/balances-and-allowances/src/updaters/BalancesBffUpdater.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { SupportedChainId } from '@cowprotocol/cow-sdk' - -import { usePersistBalancesFromBff } from '../hooks/usePersistBalancesFromBff' - -export function BalancesBffUpdater({ - account, - chainId, - invalidateCacheTrigger, - tokenAddresses, -}: { - account: string | undefined - chainId: SupportedChainId - invalidateCacheTrigger?: number - tokenAddresses: string[] -}): null { - usePersistBalancesFromBff({ - account, - chainId, - invalidateCacheTrigger, - tokenAddresses, - }) - - return null -} diff --git a/libs/balances-and-allowances/src/updaters/BalancesSseUpdater.tsx b/libs/balances-and-allowances/src/updaters/BalancesSseUpdater.tsx new file mode 100644 index 00000000000..fd0b599d534 --- /dev/null +++ b/libs/balances-and-allowances/src/updaters/BalancesSseUpdater.tsx @@ -0,0 +1,130 @@ +import { useSetAtom } from 'jotai' +import { useCallback, useEffect } from 'react' + +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { BigNumber } from '@ethersproject/bignumber' + +import { useSseBalances } from '../hooks/useSseBalances' +import { balancesAtom, BalancesState, balancesUpdateAtom } from '../state/balancesAtom' +import { useSetIsSseFailed } from '../state/isSseFailedAtom' + +export interface BalancesSseUpdaterProps { + account: string | undefined + chainId: SupportedChainId + tokenAddresses: string[] + tokensListsUrls: string[] +} + +const ZERO = BigNumber.from(0) + +function parseBalances(balances: Record): BalancesState['values'] { + return Object.entries(balances).reduce((acc, [address, balance]) => { + if (balance) { + try { + acc[address.toLowerCase()] = BigNumber.from(balance) + } catch { + // Skip invalid balances + } + } + return acc + }, {}) +} + +/** + * Initialize all token addresses with 0 balance + * This is needed because SSE only returns non-zero balances + */ +function initializeZeroBalances(tokenAddresses: string[]): BalancesState['values'] { + return tokenAddresses.reduce((acc, address) => { + acc[address.toLowerCase()] = ZERO + return acc + }, {}) +} + +export function BalancesSseUpdater({ + account, + chainId, + tokenAddresses, + tokensListsUrls, +}: BalancesSseUpdaterProps): null { + const setBalances = useSetAtom(balancesAtom) + const setBalancesUpdate = useSetAtom(balancesUpdateAtom) + const setIsSseFailed = useSetIsSseFailed() + + const onAllBalances = useCallback( + (balances: Record) => { + if (!account) return + + // Initialize all known tokens with 0 balance first + // SSE only returns non-zero balances, so tokens not in the response have 0 balance + const zeroBalances = initializeZeroBalances(tokenAddresses) + const parsed = parseBalances(balances) + + setBalances((state) => ({ + ...state, + chainId, + fromCache: false, + // First apply zero balances, then overwrite with actual balances + values: { ...state.values, ...zeroBalances, ...parsed }, + isLoading: false, + })) + + setBalancesUpdate((state) => ({ + ...state, + [chainId]: { + ...state[chainId], + [account.toLowerCase()]: Date.now(), + }, + })) + }, + [account, chainId, tokenAddresses, setBalances, setBalancesUpdate], + ) + + const onBalanceUpdate = useCallback( + (address: string, balance: string) => { + if (!balance) return + + try { + setBalances((state) => ({ + ...state, + values: { + ...state.values, + [address]: BigNumber.from(balance), + }, + })) + } catch { + // Skip invalid balance + } + }, + [setBalances], + ) + + const onError = useCallback(() => { + setIsSseFailed(true) + }, [setIsSseFailed]) + + const { isLoading, error } = useSseBalances({ + account, + chainId, + enabled: !!account, + tokensListsUrls, + customTokens: tokenAddresses, + onAllBalances, + onBalanceUpdate, + onError, + }) + + // Only mark SSE as failed when there's an actual error, not on initial state + useEffect(() => { + if (error) { + setIsSseFailed(true) + } + }, [error, setIsSseFailed]) + + // Sync loading state + useEffect(() => { + setBalances((state) => ({ ...state, isLoading, chainId })) + }, [isLoading, chainId, setBalances]) + + return null +} diff --git a/libs/balances-and-allowances/src/utils/isBffSupportedNetwork.ts b/libs/balances-and-allowances/src/utils/isBffSupportedNetwork.ts deleted file mode 100644 index 41b5a6e358e..00000000000 --- a/libs/balances-and-allowances/src/utils/isBffSupportedNetwork.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { SupportedChainId } from '@cowprotocol/cow-sdk' - -// TODO: check before Plasma launch. Currently unsupported on 2025/10/20 -const UNSUPPORTED_BFF_NETWORKS = [SupportedChainId.LENS, SupportedChainId.SEPOLIA, SupportedChainId.PLASMA] - -export function isBffSupportedNetwork(chainId: SupportedChainId): boolean { - return !UNSUPPORTED_BFF_NETWORKS.includes(chainId) -} diff --git a/libs/common-const/src/bff.ts b/libs/common-const/src/bff.ts index 248632aafe4..8cf6809f0d2 100644 --- a/libs/common-const/src/bff.ts +++ b/libs/common-const/src/bff.ts @@ -1 +1,4 @@ export const BFF_BASE_URL = process.env.REACT_APP_BFF_BASE_URL || 'https://bff.barn.cow.fi' + +// SSE Balances Service URL (token-balances-updater) +export const BALANCES_SSE_URL = process.env.REACT_APP_BALANCES_SSE_URL || 'http://localhost:4000'