From 9efdcc6e9382b72d55c4ed96288e62d40930b344 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Fri, 5 Jun 2026 14:26:43 +0400 Subject: [PATCH 01/13] feat: add balances-watcher interfaces --- .../src/balancesWatcher/createSession.test.ts | 101 ++++++++++ .../src/balancesWatcher/createSession.ts | 39 ++++ .../src/balancesWatcher/index.ts | 8 + .../subscribeToBalancesEvents.test.ts | 189 ++++++++++++++++++ .../subscribeToBalancesEvents.ts | 114 +++++++++++ .../src/balancesWatcher/types.ts | 54 +++++ libs/balances-and-allowances/src/index.ts | 16 ++ libs/common-const/src/balancesWatcher.ts | 4 + libs/common-const/src/index.ts | 1 + 9 files changed, 526 insertions(+) create mode 100644 libs/balances-and-allowances/src/balancesWatcher/createSession.test.ts create mode 100644 libs/balances-and-allowances/src/balancesWatcher/createSession.ts create mode 100644 libs/balances-and-allowances/src/balancesWatcher/index.ts create mode 100644 libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts create mode 100644 libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts create mode 100644 libs/balances-and-allowances/src/balancesWatcher/types.ts create mode 100644 libs/common-const/src/balancesWatcher.ts diff --git a/libs/balances-and-allowances/src/balancesWatcher/createSession.test.ts b/libs/balances-and-allowances/src/balancesWatcher/createSession.test.ts new file mode 100644 index 00000000000..da670d2808c --- /dev/null +++ b/libs/balances-and-allowances/src/balancesWatcher/createSession.test.ts @@ -0,0 +1,101 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { createBalancesWatcherSession } from './createSession' +import { BalancesWatcherApiError } from './types' + +const OWNER = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' +const BASE_URL = 'https://watcher.example' + +function mockFetchResponse(status: number, body: unknown): jest.SpyInstance { + const init: ResponseInit = { + status, + headers: { 'Content-Type': 'application/json' }, + } + const text = typeof body === 'string' ? body : JSON.stringify(body) + return jest.spyOn(global, 'fetch').mockResolvedValue(new Response(text, init)) +} + +describe('createBalancesWatcherSession', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('POSTs to /{chainId}/sessions/{owner} with the request body and resolves on 2xx', async () => { + const fetchSpy = mockFetchResponse(200, '') + + await createBalancesWatcherSession({ + chainId: SupportedChainId.MAINNET, + owner: OWNER, + body: { tokensListsUrls: ['https://lists.example/uni.json'], customTokens: ['0xabc'] }, + baseUrl: BASE_URL, + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [calledUrl, calledInit] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(calledUrl).toBe(`${BASE_URL}/1/sessions/${OWNER}`) + expect(calledInit.method).toBe('POST') + expect(JSON.parse(calledInit.body as string)).toEqual({ + tokensListsUrls: ['https://lists.example/uni.json'], + customTokens: ['0xabc'], + }) + }) + + it('strips a trailing slash from baseUrl', async () => { + const fetchSpy = mockFetchResponse(200, '') + + await createBalancesWatcherSession({ + chainId: SupportedChainId.GNOSIS_CHAIN, + owner: OWNER, + body: { tokensListsUrls: [], customTokens: ['0xabc'] }, + baseUrl: `${BASE_URL}/`, + }) + + const [calledUrl] = fetchSpy.mock.calls[0] + expect(calledUrl).toBe(`${BASE_URL}/100/sessions/${OWNER}`) + }) + + it('throws BalancesWatcherApiError with code+status when the server returns the JSON envelope', async () => { + mockFetchResponse(400, { code: 400, message: 'Bad request: tokens_lists_urls && custom_tokens are empty' }) + + await expect( + createBalancesWatcherSession({ + chainId: SupportedChainId.MAINNET, + owner: OWNER, + body: { tokensListsUrls: [], customTokens: [] }, + baseUrl: BASE_URL, + }), + ).rejects.toMatchObject({ + name: 'BalancesWatcherApiError', + status: 400, + code: 400, + message: 'Bad request: tokens_lists_urls && custom_tokens are empty', + }) + }) + + it('falls back to {code: status, message: raw body} when the error body is not JSON', async () => { + mockFetchResponse(503, 'upstream unreachable') + + const error = await createBalancesWatcherSession({ + chainId: SupportedChainId.MAINNET, + owner: OWNER, + body: { tokensListsUrls: ['https://lists.example/x.json'], customTokens: [] }, + baseUrl: BASE_URL, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(BalancesWatcherApiError) + expect(error).toMatchObject({ status: 503, code: 503, message: 'upstream unreachable' }) + }) + + it('surfaces 404 chain mismatch responses', async () => { + mockFetchResponse(404, { code: 404, message: 'Not found' }) + + await expect( + createBalancesWatcherSession({ + chainId: SupportedChainId.MAINNET, + owner: OWNER, + body: { tokensListsUrls: ['https://lists.example/x.json'], customTokens: [] }, + baseUrl: BASE_URL, + }), + ).rejects.toMatchObject({ status: 404, code: 404 }) + }) +}) diff --git a/libs/balances-and-allowances/src/balancesWatcher/createSession.ts b/libs/balances-and-allowances/src/balancesWatcher/createSession.ts new file mode 100644 index 00000000000..113cdaad012 --- /dev/null +++ b/libs/balances-and-allowances/src/balancesWatcher/createSession.ts @@ -0,0 +1,39 @@ +import { BALANCES_WATCHER_BASE_URL } from '@cowprotocol/common-const' +import { fetchWithTimeout, JSON_HEADERS, parseJsonResponse } from '@cowprotocol/common-utils' +import type { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { BalancesWatcherApiError, type BalancesWatcherErrorPayload, type CreateSessionRequest } from './types' + +const DEFAULT_SESSION_TIMEOUT_MS = 10_000 + +export interface CreateSessionParams { + chainId: SupportedChainId + owner: string + body: CreateSessionRequest + baseUrl?: string + timeoutMs?: number +} + +/** + * Step 1 of 2 in the balances-watcher handshake: registers the wallet with the + * watcher and tells it which token lists and individual token addresses to + * track. Step 2 — opening the SSE balance stream — is done by + * `subscribeToBalancesEvents` after this call resolves. + */ +export async function createBalancesWatcherSession(params: CreateSessionParams): Promise { + // Strip a trailing slash so the joined URL doesn't end up with `//`. + const baseUrl = (params.baseUrl ?? BALANCES_WATCHER_BASE_URL).replace(/\/$/, '') + const url = `${baseUrl}/${params.chainId}/sessions/${params.owner}` + + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(params.body), + timeout: params.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS, + }) + + if (response.ok) return + + const { data, text } = await parseJsonResponse(response) + throw new BalancesWatcherApiError(response.status, data ?? { code: response.status, message: text }) +} diff --git a/libs/balances-and-allowances/src/balancesWatcher/index.ts b/libs/balances-and-allowances/src/balancesWatcher/index.ts new file mode 100644 index 00000000000..d3e51520a2a --- /dev/null +++ b/libs/balances-and-allowances/src/balancesWatcher/index.ts @@ -0,0 +1,8 @@ +export { createBalancesWatcherSession } from './createSession' +export type { CreateSessionParams } from './createSession' + +export { subscribeToBalancesEvents } from './subscribeToBalancesEvents' +export type { BalancesSubscription, SubscribeToBalancesEventsParams } from './subscribeToBalancesEvents' + +export { BalancesWatcherApiError, BalancesWatcherStreamError } from './types' +export type { BalanceUpdateEvent, BalancesMap, BalancesWatcherErrorPayload, CreateSessionRequest } from './types' diff --git a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts new file mode 100644 index 00000000000..05fbe1f9f18 --- /dev/null +++ b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts @@ -0,0 +1,189 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { subscribeToBalancesEvents } from './subscribeToBalancesEvents' +import { BalancesWatcherStreamError } from './types' + +const OWNER = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' +const BASE_URL = 'https://watcher.example' + +interface MockEvent { + type: string + data?: string +} + +class MockEventSource { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + + readonly CONNECTING = 0 + readonly OPEN = 1 + readonly CLOSED = 2 + + readonly url: string + readyState: number = MockEventSource.OPEN + closeCallCount = 0 + + static lastInstance: MockEventSource | null = null + + private readonly listeners = new Map void>>() + + constructor(url: string) { + this.url = url + MockEventSource.lastInstance = this + } + + addEventListener(name: string, fn: (event: MockEvent) => void): void { + if (!this.listeners.has(name)) this.listeners.set(name, new Set()) + this.listeners.get(name)?.add(fn) + } + + close(): void { + this.closeCallCount++ + this.readyState = MockEventSource.CLOSED + } + + emit(name: string, data?: string): void { + const event: MockEvent = data !== undefined ? { type: name, data } : { type: name } + this.listeners.get(name)?.forEach((fn) => fn(event)) + } +} + +function getSseCallbacks(): { + onBalances: jest.Mock + onError: jest.Mock +} { + return { onBalances: jest.fn(), onError: jest.fn() } +} + +function start(extra: Partial[0]> = {}): { + cbs: ReturnType + source: MockEventSource + subscription: ReturnType +} { + const cbs = getSseCallbacks() + const subscription = subscribeToBalancesEvents({ + chainId: SupportedChainId.MAINNET, + owner: OWNER, + baseUrl: BASE_URL, + EventSourceCtor: MockEventSource as unknown as typeof EventSource, + ...cbs, + ...extra, + }) + const source = MockEventSource.lastInstance + if (!source) throw new Error('MockEventSource was not constructed') + return { cbs, source, subscription } +} + +describe('subscribeToBalancesEvents', () => { + beforeEach(() => { + MockEventSource.lastInstance = null + }) + + it('connects to /sse/{chainId}/balances/{owner}', () => { + const { source } = start() + expect(source.url).toBe(`${BASE_URL}/sse/1/balances/${OWNER}`) + }) + + it('delivers every balance_update payload to onBalances in order', () => { + const { source, cbs } = start() + + source.emit('balance_update', JSON.stringify({ balances: { '0xtoken1': '100' } })) + source.emit('balance_update', JSON.stringify({ balances: { '0xtoken1': '150' } })) + source.emit('balance_update', JSON.stringify({ balances: { '0xtoken2': '99' } })) + + expect(cbs.onBalances).toHaveBeenCalledTimes(3) + expect(cbs.onBalances).toHaveBeenNthCalledWith(1, { '0xtoken1': '100' }) + expect(cbs.onBalances).toHaveBeenNthCalledWith(2, { '0xtoken1': '150' }) + expect(cbs.onBalances).toHaveBeenNthCalledWith(3, { '0xtoken2': '99' }) + expect(cbs.onError).not.toHaveBeenCalled() + }) + + it('treats a missing balances field as an empty map', () => { + const { source, cbs } = start() + + source.emit('balance_update', JSON.stringify({})) + + expect(cbs.onBalances).toHaveBeenCalledWith({}) + }) + + it('reports a non-terminal error and keeps the subscription open when payload JSON is invalid', () => { + const { source, cbs } = start() + + source.emit('balance_update', '{not json}') + + expect(cbs.onError).toHaveBeenCalledTimes(1) + const [error, terminal] = cbs.onError.mock.calls[0] + expect(terminal).toBe(false) + expect((error as Error).message).toMatch(/Failed to parse balance_update payload/) + expect(source.closeCallCount).toBe(0) + + // Follow-up valid event still reaches onBalances. + source.emit('balance_update', JSON.stringify({ balances: { '0xtoken1': '7' } })) + expect(cbs.onBalances).toHaveBeenCalledWith({ '0xtoken1': '7' }) + }) + + it('treats a server-sent error event (with data) as terminal and closes the source', () => { + const { source, cbs } = start() + + source.emit('error', JSON.stringify({ code: 503, message: 'WebSocket connection lost permanently' })) + + expect(cbs.onError).toHaveBeenCalledTimes(1) + const [error, terminal] = cbs.onError.mock.calls[0] + expect(terminal).toBe(true) + expect(error).toBeInstanceOf(BalancesWatcherStreamError) + expect((error as BalancesWatcherStreamError).code).toBe(503) + expect(source.closeCallCount).toBe(1) + }) + + it('falls back to a synthetic payload when the server error data is not parseable', () => { + const { source, cbs } = start() + + source.emit('error', 'not json') + + const [error, terminal] = cbs.onError.mock.calls[0] + expect(terminal).toBe(true) + expect(error).toBeInstanceOf(BalancesWatcherStreamError) + expect((error as BalancesWatcherStreamError).code).toBe(0) + }) + + it('treats a native transport error (no data) as non-terminal while EventSource is reconnecting', () => { + const { source, cbs } = start() + source.readyState = MockEventSource.CONNECTING + + source.emit('error') + + const [, terminal] = cbs.onError.mock.calls[0] + expect(terminal).toBe(false) + expect(source.closeCallCount).toBe(0) + }) + + it('marks a transport error as terminal once EventSource is CLOSED', () => { + const { source, cbs } = start() + source.readyState = MockEventSource.CLOSED + + source.emit('error') + + const [, terminal] = cbs.onError.mock.calls[0] + expect(terminal).toBe(true) + }) + + it('close() prevents further callbacks and is idempotent', () => { + const { source, cbs, subscription } = start() + + subscription.close() + subscription.close() + + source.emit('balance_update', JSON.stringify({ balances: { '0xtoken1': '1' } })) + source.emit('error', JSON.stringify({ code: 1, message: 'late' })) + + expect(source.closeCallCount).toBe(1) + expect(cbs.onBalances).not.toHaveBeenCalled() + expect(cbs.onError).not.toHaveBeenCalled() + }) + + it('strips a trailing slash from baseUrl', () => { + const { source } = start({ baseUrl: `${BASE_URL}/` }) + expect(source.url).toBe(`${BASE_URL}/sse/1/balances/${OWNER}`) + }) +}) diff --git a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts new file mode 100644 index 00000000000..72756e54708 --- /dev/null +++ b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts @@ -0,0 +1,114 @@ +import { BALANCES_WATCHER_BASE_URL } from '@cowprotocol/common-const' +import type { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { + type BalanceUpdateEvent, + type BalancesMap, + type BalancesWatcherErrorPayload, + BalancesWatcherStreamError, +} from './types' + +const BALANCE_UPDATE_EVENT = 'balance_update' +const ERROR_EVENT = 'error' +const UNPARSEABLE_ERROR_FALLBACK: BalancesWatcherErrorPayload = { + code: 0, + message: 'Unparseable error payload from balances watcher', +} + +export interface BalancesSubscription { + close(): void +} + +export interface SubscribeToBalancesEventsParams { + chainId: SupportedChainId + owner: string + baseUrl?: string + /** + * Called for every `balance_update` SSE event. The first event after connect + * is the full snapshot (includes zeros for empty balances); every subsequent + * event contains only the balances that changed. Consumers should merge the + * payload into their balance map in both cases. + */ + onBalances: (balances: BalancesMap) => void + /** + * Called on any error. `terminal=true` means the subscription has been closed + * — either because the server sent `event: error` or because the underlying + * EventSource transitioned to CLOSED. `terminal=false` means EventSource is + * still attempting to reconnect. + */ + onError: (error: Error, terminal: boolean) => void + /** + * Override EventSource constructor — for tests. + */ + EventSourceCtor?: typeof EventSource +} + +function tryParseJson(input: string): T | undefined { + try { + return JSON.parse(input) as T + } catch { + return undefined + } +} + +export function subscribeToBalancesEvents(params: SubscribeToBalancesEventsParams): BalancesSubscription { + const baseUrl = (params.baseUrl ?? BALANCES_WATCHER_BASE_URL).replace(/\/$/, '') + const url = `${baseUrl}/sse/${params.chainId}/balances/${params.owner}` + const EventSourceConstructor = params.EventSourceCtor ?? globalThis.EventSource + + if (!EventSourceConstructor) { + throw new Error('EventSource is not available in this environment') + } + + let closed = false + const eventSource = new EventSourceConstructor(url) + + const handleBalanceUpdate = (event: MessageEvent): void => { + if (closed) return + + const payload = tryParseJson(event.data) + if (!payload) { + params.onError(new Error(`Failed to parse balance_update payload: ${event.data}`), false) + return + } + + params.onBalances(payload.balances ?? {}) + } + + const handleErrorEvent = (event: Event): void => { + if (closed) return + + // The server emits `event: error` with a JSON envelope when it terminates + // the stream. EventSource also dispatches its own native 'error' event on + // transport failure — those events have no `data` field. + const data = (event as MessageEvent).data + const isServerError = typeof data === 'string' && data.length > 0 + + if (isServerError) { + const payload = tryParseJson(data) ?? UNPARSEABLE_ERROR_FALLBACK + closed = true + eventSource.close() + params.onError(new BalancesWatcherStreamError(payload), true) + return + } + + // Transport-level failure. EventSource auto-reconnects unless readyState + // is CLOSED (e.g. server returned non-200 on connect). + const terminal = eventSource.readyState === eventSource.CLOSED + if (terminal) { + closed = true + } + params.onError(new Error('Balances watcher SSE transport error'), terminal) + } + + eventSource.addEventListener(BALANCE_UPDATE_EVENT, handleBalanceUpdate as EventListener) + eventSource.addEventListener(ERROR_EVENT, handleErrorEvent) + + return { + close(): void { + if (closed) return + closed = true + eventSource.close() + }, + } +} diff --git a/libs/balances-and-allowances/src/balancesWatcher/types.ts b/libs/balances-and-allowances/src/balancesWatcher/types.ts new file mode 100644 index 00000000000..0d102abd655 --- /dev/null +++ b/libs/balances-and-allowances/src/balancesWatcher/types.ts @@ -0,0 +1,54 @@ +/** + * Request body for POST /{chain_id}/sessions/{owner}. + * + * The server rejects (400) if both arrays are empty — callers must guard + * against that before invoking createBalancesWatcherSession. + */ +export interface CreateSessionRequest { + tokensListsUrls: string[] + customTokens: string[] +} + +/** + * Map from token address (or the zero address for native) to balance as a + * decimal string. Snapshot = full map; diff = only addresses whose balance + * changed since the previous SSE event. + */ +export type BalancesMap = Record + +export interface BalanceUpdateEvent { + balances: BalancesMap +} + +export interface BalancesWatcherErrorPayload { + code: number + message: string +} + +export class BalancesWatcherApiError extends Error { + readonly status: number + readonly code: number + + constructor(status: number, payload: BalancesWatcherErrorPayload) { + super(payload.message || `Balances watcher API error (${status})`) + this.name = 'BalancesWatcherApiError' + this.status = status + this.code = payload.code + } +} + +/** + * Terminal error delivered over the SSE channel as `event: error`. The + * subscription is closed after this fires. + * + * TODO: add client-side auto-reconnect with backoff (planned for the hardening PR). + */ +export class BalancesWatcherStreamError extends Error { + readonly code: number + + constructor(payload: BalancesWatcherErrorPayload) { + super(payload.message || `Balances watcher stream error (${payload.code})`) + this.name = 'BalancesWatcherStreamError' + this.code = payload.code + } +} diff --git a/libs/balances-and-allowances/src/index.ts b/libs/balances-and-allowances/src/index.ts index 473550242f4..a895da37148 100644 --- a/libs/balances-and-allowances/src/index.ts +++ b/libs/balances-and-allowances/src/index.ts @@ -22,3 +22,19 @@ export type { AllowancesState } from './hooks/useTokenAllowances' // Consts export { DEFAULT_BALANCES_STATE } from './state/balancesAtom' + +export { + createBalancesWatcherSession, + subscribeToBalancesEvents, + BalancesWatcherApiError, + BalancesWatcherStreamError, +} from './balancesWatcher' +export type { + BalanceUpdateEvent, + BalancesMap, + BalancesSubscription, + BalancesWatcherErrorPayload, + CreateSessionParams, + CreateSessionRequest, + SubscribeToBalancesEventsParams, +} from './balancesWatcher' diff --git a/libs/common-const/src/balancesWatcher.ts b/libs/common-const/src/balancesWatcher.ts new file mode 100644 index 00000000000..73d8d4a7685 --- /dev/null +++ b/libs/common-const/src/balancesWatcher.ts @@ -0,0 +1,4 @@ +const BALANCES_WATCHER_BASE_URL_DEFAULT = 'https://balance-watcher.info' + +export const BALANCES_WATCHER_BASE_URL = + process.env['REACT_APP_BALANCES_WATCHER_BASE_URL'] || BALANCES_WATCHER_BASE_URL_DEFAULT diff --git a/libs/common-const/src/index.ts b/libs/common-const/src/index.ts index 85b392c03cc..0209c0a89f0 100644 --- a/libs/common-const/src/index.ts +++ b/libs/common-const/src/index.ts @@ -1,3 +1,4 @@ +export * from './balancesWatcher' export * from './bff' export * from './chainInfo' export * from './cdn' From 53e049f69b9e7483648188af83ef7fbcf35e8405 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Sat, 6 Jun 2026 22:35:28 +0400 Subject: [PATCH 02/13] fix(balances): terminate SSE on malformed balance_update --- .../subscribeToBalancesEvents.test.ts | 23 +++++++++++++------ .../subscribeToBalancesEvents.ts | 22 ++++++++++++++---- .../src/balancesWatcher/types.ts | 6 ++--- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts index 05fbe1f9f18..461560f797a 100644 --- a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts +++ b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts @@ -99,28 +99,37 @@ describe('subscribeToBalancesEvents', () => { expect(cbs.onError).not.toHaveBeenCalled() }) - it('treats a missing balances field as an empty map', () => { + it('treats a balance_update without a `balances` field as terminal corruption and closes the source', () => { const { source, cbs } = start() source.emit('balance_update', JSON.stringify({})) - expect(cbs.onBalances).toHaveBeenCalledWith({}) + expect(cbs.onBalances).not.toHaveBeenCalled() + expect(cbs.onError).toHaveBeenCalledTimes(1) + const [error, terminal] = cbs.onError.mock.calls[0] + expect(terminal).toBe(true) + expect((error as Error).message).toMatch(/missing `balances` field/) + expect(source.closeCallCount).toBe(1) + + // Follow-up valid event must NOT reach onBalances (subscription is closed). + source.emit('balance_update', JSON.stringify({ balances: { '0xtoken1': '7' } })) + expect(cbs.onBalances).not.toHaveBeenCalled() }) - it('reports a non-terminal error and keeps the subscription open when payload JSON is invalid', () => { + it('treats invalid JSON in a balance_update as terminal corruption and closes the source', () => { const { source, cbs } = start() source.emit('balance_update', '{not json}') expect(cbs.onError).toHaveBeenCalledTimes(1) const [error, terminal] = cbs.onError.mock.calls[0] - expect(terminal).toBe(false) + expect(terminal).toBe(true) expect((error as Error).message).toMatch(/Failed to parse balance_update payload/) - expect(source.closeCallCount).toBe(0) + expect(source.closeCallCount).toBe(1) - // Follow-up valid event still reaches onBalances. + // Follow-up valid event must NOT reach onBalances (subscription is closed). source.emit('balance_update', JSON.stringify({ balances: { '0xtoken1': '7' } })) - expect(cbs.onBalances).toHaveBeenCalledWith({ '0xtoken1': '7' }) + expect(cbs.onBalances).not.toHaveBeenCalled() }) it('treats a server-sent error event (with data) as terminal and closes the source', () => { diff --git a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts index 72756e54708..569f3f9667d 100644 --- a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts +++ b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts @@ -63,16 +63,30 @@ export function subscribeToBalancesEvents(params: SubscribeToBalancesEventsParam let closed = false const eventSource = new EventSourceConstructor(url) + const terminate = (error: Error): void => { + closed = true + eventSource.close() + params.onError(error, true) + } + + // Any malformed or schema-invalid `balance_update` corrupts the local map + // (missed snapshot ⇒ no baseline; missed diff ⇒ permanent drift, because the + // server only emits changed addresses going forward). Close the stream so + // the caller can re-create the session from a fresh snapshot. const handleBalanceUpdate = (event: MessageEvent): void => { if (closed) return const payload = tryParseJson(event.data) if (!payload) { - params.onError(new Error(`Failed to parse balance_update payload: ${event.data}`), false) + terminate(new Error(`Failed to parse balance_update payload: ${event.data}`)) + return + } + if (!payload.balances) { + terminate(new Error('balance_update payload missing `balances` field')) return } - params.onBalances(payload.balances ?? {}) + params.onBalances(payload.balances) } const handleErrorEvent = (event: Event): void => { @@ -86,9 +100,7 @@ export function subscribeToBalancesEvents(params: SubscribeToBalancesEventsParam if (isServerError) { const payload = tryParseJson(data) ?? UNPARSEABLE_ERROR_FALLBACK - closed = true - eventSource.close() - params.onError(new BalancesWatcherStreamError(payload), true) + terminate(new BalancesWatcherStreamError(payload)) return } diff --git a/libs/balances-and-allowances/src/balancesWatcher/types.ts b/libs/balances-and-allowances/src/balancesWatcher/types.ts index 0d102abd655..0a1b7aa549e 100644 --- a/libs/balances-and-allowances/src/balancesWatcher/types.ts +++ b/libs/balances-and-allowances/src/balancesWatcher/types.ts @@ -10,9 +10,9 @@ export interface CreateSessionRequest { } /** - * Map from token address (or the zero address for native) to balance as a - * decimal string. Snapshot = full map; diff = only addresses whose balance - * changed since the previous SSE event. + * Map from token address (or the `0xeeee…eeee` native sentinel for the chain's + * native currency) to balance as a decimal string. Snapshot = full map; diff = + * only addresses whose balance changed since the previous SSE event. */ export type BalancesMap = Record From d9c3c1ae5abd1622dd05687f32526232798e6521 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 10 Jun 2026 17:28:32 +0400 Subject: [PATCH 03/13] chore: update balances-watcher default URL to staging endpoint --- libs/common-const/src/balancesWatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/common-const/src/balancesWatcher.ts b/libs/common-const/src/balancesWatcher.ts index 73d8d4a7685..12d5bf8b96e 100644 --- a/libs/common-const/src/balancesWatcher.ts +++ b/libs/common-const/src/balancesWatcher.ts @@ -1,4 +1,4 @@ -const BALANCES_WATCHER_BASE_URL_DEFAULT = 'https://balance-watcher.info' +const BALANCES_WATCHER_BASE_URL_DEFAULT = 'https://balances-watcher.barn.cow.fi' export const BALANCES_WATCHER_BASE_URL = process.env['REACT_APP_BALANCES_WATCHER_BASE_URL'] || BALANCES_WATCHER_BASE_URL_DEFAULT From b9143098ebf07a827cd8a49762ff6d49db150fc0 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 10 Jun 2026 21:01:50 +0400 Subject: [PATCH 04/13] feat: add bw updater --- ...onPriorityBalancesAndAllowancesUpdater.tsx | 8 + .../subscribeToBalancesEvents.test.ts | 21 +- .../hooks/useBalancesWatcherSession.test.tsx | 300 ++++++++++++++++++ .../src/hooks/useBalancesWatcherSession.ts | 128 ++++++++ .../hooks/useCustomTokensForChain.test.tsx | 100 ++++++ .../src/hooks/useCustomTokensForChain.ts | 18 ++ .../src/hooks/useEnabledTokensListsUrls.ts | 16 + .../src/hooks/useStableStringArray.test.tsx | 62 ++++ .../src/hooks/useStableStringArray.ts | 28 ++ libs/balances-and-allowances/src/index.ts | 1 + .../src/updaters/BalancesWatcherUpdater.tsx | 37 +++ 11 files changed, 709 insertions(+), 10 deletions(-) create mode 100644 libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx create mode 100644 libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts create mode 100644 libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx create mode 100644 libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts create mode 100644 libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts create mode 100644 libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx create mode 100644 libs/balances-and-allowances/src/hooks/useStableStringArray.ts create mode 100644 libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx diff --git a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx index 44d85d215ea..09636601c38 100644 --- a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx @@ -2,9 +2,11 @@ import { ReactNode, useEffect, useMemo, useState } from 'react' import { BalancesAndAllowancesUpdater, + BalancesWatcherUpdater, PRIORITY_TOKENS_REFRESH_INTERVAL, PriorityTokensUpdater, } from '@cowprotocol/balances-and-allowances' +import { useFeatureFlags } from '@cowprotocol/common-hooks' import { useWalletInfo } from '@cowprotocol/wallet' import { useBalancesContext } from 'entities/balancesContext/useBalancesContext' @@ -20,6 +22,8 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const balancesContext = useBalancesContext() const balancesAccount = balancesContext.account || account + const { useBalancesWatcher: isBalancesWatcherEnabled } = useFeatureFlags() + const priorityTokenAddresses = usePriorityTokenAddresses() const priorityTokenAddressesAsArray = useMemo(() => { return Array.from(priorityTokenAddresses.values()) @@ -52,6 +56,10 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const refreshTrigger = useOrdersFilledEventsTrigger() + if (isBalancesWatcherEnabled) { + return + } + return ( <> [0]> = {}): { +function start(extra: Partial = {}): { cbs: ReturnType source: MockEventSource subscription: ReturnType } { const cbs = getSseCallbacks() - const subscription = subscribeToBalancesEvents({ - chainId: SupportedChainId.MAINNET, - owner: OWNER, - baseUrl: BASE_URL, - EventSourceCtor: MockEventSource as unknown as typeof EventSource, - ...cbs, - ...extra, - }) + const params: SubscribeToBalancesEventsParams = { + chainId: extra.chainId ?? SupportedChainId.MAINNET, + owner: extra.owner ?? OWNER, + baseUrl: extra.baseUrl ?? BASE_URL, + EventSourceCtor: extra.EventSourceCtor ?? (MockEventSource as unknown as typeof EventSource), + onBalances: extra.onBalances ?? cbs.onBalances, + onError: extra.onError ?? cbs.onError, + } + const subscription = subscribeToBalancesEvents(params) const source = MockEventSource.lastInstance if (!source) throw new Error('MockEventSource was not constructed') return { cbs, source, subscription } diff --git a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx new file mode 100644 index 00000000000..31b651effd5 --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx @@ -0,0 +1,300 @@ +import { Provider, useAtomValue } from 'jotai' +import { useHydrateAtoms } from 'jotai/utils' +import React, { ReactNode } from 'react' + +import { NATIVE_CURRENCY_ADDRESS } from '@cowprotocol/common-const' +import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' + +import { act, renderHook } from '@testing-library/react' + +import { useBalancesWatcherSession, UseBalancesWatcherSessionParams } from './useBalancesWatcherSession' + +import { BalancesSubscription, BalancesWatcherApiError, SubscribeToBalancesEventsParams } from '../balancesWatcher' +import { balancesAtom, BalancesState, DEFAULT_BALANCES_STATE } from '../state/balancesAtom' + +jest.mock('../balancesWatcher', () => { + const actual = jest.requireActual('../balancesWatcher') + return { + ...actual, + createBalancesWatcherSession: jest.fn(), + subscribeToBalancesEvents: jest.fn(), + } +}) + +const balancesWatcherModule = jest.requireMock('../balancesWatcher') as { + createBalancesWatcherSession: jest.Mock + subscribeToBalancesEvents: jest.Mock +} +const mockCreateSession = balancesWatcherModule.createBalancesWatcherSession +const mockSubscribe = balancesWatcherModule.subscribeToBalancesEvents + +interface Deferred { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const ACCOUNT = '0x1234567890123456789012345678901234567890' +const TOKEN_A = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const TOKEN_B = '0xdAC17F958D2ee523a2206206994597C13D831ec7' + +function makeParams(overrides: Partial = {}): UseBalancesWatcherSessionParams { + return { + account: ACCOUNT, + chainId: SupportedChainId.MAINNET, + tokensListsUrls: ['https://example.com/tokens.json'], + customTokens: [], + ...overrides, + } +} + +let currentInitialBalances: BalancesState = DEFAULT_BALANCES_STATE + +function HydrateAtoms({ children }: { children: ReactNode }): ReactNode { + useHydrateAtoms([[balancesAtom, currentInitialBalances]]) + return <>{children} +} + +function Wrapper({ children }: { children: ReactNode }): ReactNode { + return ( + + {children} + + ) +} + +function renderSession( + initialParams: UseBalancesWatcherSessionParams = makeParams(), + initialBalances: BalancesState = DEFAULT_BALANCES_STATE, +): ReturnType> { + currentInitialBalances = initialBalances + return renderHook( + ({ params }: { params: UseBalancesWatcherSessionParams }) => { + useBalancesWatcherSession(params) + return useAtomValue(balancesAtom) + }, + { wrapper: Wrapper, initialProps: { params: initialParams } }, + ) +} + +function capturedSubscribeParams(): SubscribeToBalancesEventsParams { + const calls = mockSubscribe.mock.calls + expect(calls.length).toBeGreaterThan(0) + return calls[calls.length - 1][0] as SubscribeToBalancesEventsParams +} + +describe('useBalancesWatcherSession', () => { + beforeEach(() => { + jest.clearAllMocks() + mockCreateSession.mockReturnValue(Promise.resolve()) + mockSubscribe.mockReturnValue({ close: jest.fn() } satisfies BalancesSubscription) + }) + + it('does not create a session when account is undefined', () => { + renderSession(makeParams({ account: undefined })) + + expect(mockCreateSession).not.toHaveBeenCalled() + expect(mockSubscribe).not.toHaveBeenCalled() + }) + + it('does not create a session when both lists and customTokens are empty', () => { + renderSession(makeParams({ tokensListsUrls: [], customTokens: [] })) + + expect(mockCreateSession).not.toHaveBeenCalled() + }) + + it('does not create a session for a non-EVM chain (Solana)', () => { + renderSession(makeParams({ chainId: SupportedChainId.SOLANA })) + + expect(mockCreateSession).not.toHaveBeenCalled() + }) + + it('creates a session with the expected body and subscribes after it resolves', async () => { + const session = deferred() + mockCreateSession.mockReturnValueOnce(session.promise) + + renderSession(makeParams({ customTokens: [TOKEN_A.toLowerCase()] })) + + expect(mockCreateSession).toHaveBeenCalledTimes(1) + expect(mockCreateSession).toHaveBeenCalledWith({ + chainId: SupportedChainId.MAINNET, + owner: ACCOUNT, + body: { + tokensListsUrls: ['https://example.com/tokens.json'], + customTokens: [TOKEN_A.toLowerCase()], + }, + }) + expect(mockSubscribe).not.toHaveBeenCalled() + + await act(async () => { + session.resolve() + }) + + expect(mockSubscribe).toHaveBeenCalledTimes(1) + expect(capturedSubscribeParams()).toMatchObject({ + chainId: SupportedChainId.MAINNET, + owner: ACCOUNT, + }) + }) + + it('writes the snapshot into balancesAtom (bigint values, normalized address keys, first-load flags)', async () => { + const session = deferred() + mockCreateSession.mockReturnValueOnce(session.promise) + + const { result } = renderSession() + + await act(async () => { + session.resolve() + }) + + await act(async () => { + capturedSubscribeParams().onBalances({ + [NATIVE_CURRENCY_ADDRESS]: '1000000000000000000', + [TOKEN_A]: '500', + }) + }) + + expect(result.current.values[getAddressKey(NATIVE_CURRENCY_ADDRESS)]).toBe(1000000000000000000n) + expect(result.current.values[getAddressKey(TOKEN_A)]).toBe(500n) + expect(result.current.hasFirstLoad).toBe(true) + expect(result.current.isLoading).toBe(false) + expect(result.current.fromCache).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.chainId).toBe(SupportedChainId.MAINNET) + }) + + it('merges a diff into balancesAtom without clearing prior keys', async () => { + const session = deferred() + mockCreateSession.mockReturnValueOnce(session.promise) + + const { result } = renderSession() + + await act(async () => { + session.resolve() + }) + const sub = capturedSubscribeParams() + + await act(async () => { + sub.onBalances({ [TOKEN_A]: '100', [TOKEN_B]: '200' }) + }) + await act(async () => { + sub.onBalances({ [TOKEN_B]: '999' }) + }) + + expect(result.current.values[getAddressKey(TOKEN_A)]).toBe(100n) + expect(result.current.values[getAddressKey(TOKEN_B)]).toBe(999n) + }) + + it('writes the atom error and clears isLoading on a terminal SSE error', async () => { + const session = deferred() + mockCreateSession.mockReturnValueOnce(session.promise) + + const { result } = renderSession() + + await act(async () => { + session.resolve() + }) + const sub = capturedSubscribeParams() + + await act(async () => { + sub.onError(new Error('stream closed by server'), true) + }) + + expect(result.current.error).toBe('stream closed by server') + expect(result.current.isLoading).toBe(false) + }) + + it('ignores non-terminal SSE errors (transport is reconnecting)', async () => { + const session = deferred() + mockCreateSession.mockReturnValueOnce(session.promise) + + const { result } = renderSession() + + await act(async () => { + session.resolve() + }) + const sub = capturedSubscribeParams() + + await act(async () => { + sub.onError(new Error('transient'), false) + }) + + expect(result.current.error).toBeNull() + }) + + it('writes the atom error and clears isLoading when createSession rejects', async () => { + const session = deferred() + mockCreateSession.mockReturnValueOnce(session.promise) + + const { result } = renderSession() + + await act(async () => { + session.reject(new BalancesWatcherApiError(503, { code: 1, message: 'service unavailable' })) + }) + + expect(result.current.error).toBe('service unavailable') + expect(result.current.isLoading).toBe(false) + expect(mockSubscribe).not.toHaveBeenCalled() + }) + + it('closes the subscription on unmount and ignores late events', async () => { + const session = deferred() + mockCreateSession.mockReturnValueOnce(session.promise) + const close = jest.fn() + mockSubscribe.mockReturnValueOnce({ close }) + + const { result, unmount } = renderSession() + + await act(async () => { + session.resolve() + }) + const sub = capturedSubscribeParams() + + unmount() + expect(close).toHaveBeenCalledTimes(1) + + await act(async () => { + sub.onBalances({ [TOKEN_A]: '777' }) + }) + expect(result.current.values[getAddressKey(TOKEN_A)]).toBeUndefined() + }) + + it('discards a session whose POST resolves after a chainId change (race-guard)', async () => { + const stale = deferred() + const fresh = deferred() + mockCreateSession.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise) + + const { result, rerender } = renderSession(makeParams({ chainId: SupportedChainId.MAINNET })) + + rerender({ params: makeParams({ chainId: SupportedChainId.ARBITRUM_ONE }) }) + + // Stale chain=1 session resolves first; it must NOT open a subscription. + await act(async () => { + stale.resolve() + }) + expect(mockSubscribe).not.toHaveBeenCalled() + + // Fresh chain=42161 session resolves; subscription opens for that chain. + await act(async () => { + fresh.resolve() + }) + expect(mockSubscribe).toHaveBeenCalledTimes(1) + expect(capturedSubscribeParams().chainId).toBe(SupportedChainId.ARBITRUM_ONE) + + await act(async () => { + capturedSubscribeParams().onBalances({ [TOKEN_A]: '42' }) + }) + expect(result.current.chainId).toBe(SupportedChainId.ARBITRUM_ONE) + expect(result.current.values[getAddressKey(TOKEN_A)]).toBe(42n) + }) +}) diff --git a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts new file mode 100644 index 00000000000..f892860e070 --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts @@ -0,0 +1,128 @@ +import { useSetAtom } from 'jotai' +import { useEffect, useRef } from 'react' + +import { getAddressKey, isEvmChain, SupportedChainId } from '@cowprotocol/cow-sdk' + +import { + type BalancesMap, + type BalancesSubscription, + createBalancesWatcherSession, + subscribeToBalancesEvents, +} from '../balancesWatcher' +import { balancesAtom, BalancesState } from '../state/balancesAtom' + +export interface UseBalancesWatcherSessionParams { + account: string | undefined + chainId: SupportedChainId + /** + * Enabled token list source URLs. Sent verbatim in the session POST. + */ + tokensListsUrls: string[] + /** + * Custom (user-imported) token addresses for the current chain. Sent verbatim + * in the session POST. + */ + customTokens: string[] +} + +/** + * Owns the (POST session → open SSE) lifecycle for the balances watcher and + * writes incoming balance updates into `balancesAtom`. Replaces the multicall + * pipeline when the `useBalancesWatcher` LD flag is on. + * + * Lifecycle: a new session is created whenever account, chainId, or the set of + * lists/custom tokens changes. The previous EventSource is closed and any + * in-flight POST is invalidated via an epoch ref (the transport's + * `fetchWithTimeout` does not honor an external signal — adding signal support + * is a follow-up for the hardening PR). + */ +export function useBalancesWatcherSession(params: UseBalancesWatcherSessionParams): void { + const { account, chainId, tokensListsUrls, customTokens } = params + + const setBalances = useSetAtom(balancesAtom) + const epochRef = useRef(0) + + // The `tokensListsUrls` and `customTokens` arrays are expected to be + // referentially stable across renders (callers memoize them). The session is + // re-created whenever any of the deps below change identity. + useEffect(() => { + if (!account) return + if (!isEvmChain(chainId)) return + // Server rejects (400) when both arrays are empty — skip session creation + // until the user enables a list or imports a token. + if (tokensListsUrls.length === 0 && customTokens.length === 0) return + + const epoch = ++epochRef.current + let subscription: BalancesSubscription | undefined + let isFirstEvent = true + + setBalances((state) => ({ ...state, isLoading: true, chainId, error: null })) + + createBalancesWatcherSession({ + chainId, + owner: account, + body: { tokensListsUrls, customTokens }, + }) + .then(() => { + if (epoch !== epochRef.current) return + + subscription = subscribeToBalancesEvents({ + chainId, + owner: account, + onBalances: (balances) => { + if (epoch !== epochRef.current) return + setBalances((state) => writeBalancesUpdate(state, balances, chainId, isFirstEvent)) + isFirstEvent = false + }, + onError: (error, terminal) => { + if (epoch !== epochRef.current) return + // Non-terminal errors mean EventSource is reconnecting — the + // transport recovers on its own. Only surface terminal errors. + if (!terminal) return + setBalances((state) => ({ ...state, error: error.message, isLoading: false })) + }, + }) + }) + .catch((error: unknown) => { + if (epoch !== epochRef.current) return + const message = error instanceof Error ? error.message : String(error) + setBalances((state) => ({ ...state, error: message, isLoading: false })) + }) + + return () => { + // Bumping the epoch invalidates any pending `.then` / SSE callback that + // resolves after teardown — `epoch` (captured) will no longer match + // `epochRef.current`. Mutation in the cleanup is intentional here. + // eslint-disable-next-line react-hooks/exhaustive-deps + epochRef.current++ + subscription?.close() + } + }, [account, chainId, tokensListsUrls, customTokens, setBalances]) +} + +function writeBalancesUpdate( + state: BalancesState, + payload: BalancesMap, + chainId: SupportedChainId, + isFirstEvent: boolean, +): BalancesState { + const merged: BalancesState['values'] = { ...state.values } + for (const rawAddress of Object.keys(payload)) { + const normalizedAddress = getAddressKey(rawAddress) + merged[normalizedAddress] = BigInt(payload[rawAddress]) + } + + return { + ...state, + chainId, + values: merged, + ...(isFirstEvent + ? { + fromCache: false, + hasFirstLoad: true, + error: null, + isLoading: false, + } + : {}), + } +} diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx new file mode 100644 index 00000000000..851bab4e54a --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx @@ -0,0 +1,100 @@ +import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' +import { useUserAddedTokens } from '@cowprotocol/tokens' + +import { renderHook } from '@testing-library/react' + +import { useCustomTokensForChain } from './useCustomTokensForChain' + +jest.mock('@cowprotocol/tokens', () => ({ + useUserAddedTokens: jest.fn(), +})) + +const useUserAddedTokensMock = jest.requireMock<{ useUserAddedTokens: jest.Mock }>( + '@cowprotocol/tokens', +).useUserAddedTokens + +const TOKEN_A = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const TOKEN_B = '0xdAC17F958D2ee523a2206206994597C13D831ec7' +const TOKEN_C = '0x6B175474E89094C44Da98b954EedeAC495271d0F' + +type MinimalToken = { chainId: SupportedChainId; address: string } + +function mockTokens(tokens: MinimalToken[]): void { + useUserAddedTokensMock.mockReturnValue(tokens as unknown as ReturnType) +} + +describe('useCustomTokensForChain', () => { + beforeEach(() => { + useUserAddedTokensMock.mockReset() + }) + + it('returns an empty array when no user-added tokens exist', () => { + mockTokens([]) + + const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + + expect(result.current).toEqual([]) + }) + + it('filters tokens by chainId', () => { + mockTokens([ + { chainId: SupportedChainId.MAINNET, address: TOKEN_A }, + { chainId: SupportedChainId.ARBITRUM_ONE, address: TOKEN_B }, + { chainId: SupportedChainId.MAINNET, address: TOKEN_C }, + ]) + + const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + + expect(result.current).toEqual([getAddressKey(TOKEN_A), getAddressKey(TOKEN_C)].sort()) + }) + + it('normalizes addresses via getAddressKey', () => { + mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) + + const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + + expect(result.current).toEqual([getAddressKey(TOKEN_A)]) + }) + + it('returns the addresses sorted', () => { + mockTokens([ + { chainId: SupportedChainId.MAINNET, address: TOKEN_C }, + { chainId: SupportedChainId.MAINNET, address: TOKEN_A }, + { chainId: SupportedChainId.MAINNET, address: TOKEN_B }, + ]) + + const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + + const expected = [TOKEN_A, TOKEN_B, TOKEN_C].map(getAddressKey).sort() + expect(result.current).toEqual(expected) + }) + + it('preserves array identity across renders when the resulting set is equal', () => { + mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) + + const { result, rerender } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + const first = result.current + + // Different array reference, same content. + mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) + rerender() + + expect(result.current).toBe(first) + }) + + it('emits a new array identity when the set of addresses changes', () => { + mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) + + const { result, rerender } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + const first = result.current + + mockTokens([ + { chainId: SupportedChainId.MAINNET, address: TOKEN_A }, + { chainId: SupportedChainId.MAINNET, address: TOKEN_B }, + ]) + rerender() + + expect(result.current).not.toBe(first) + expect(result.current).toEqual([getAddressKey(TOKEN_A), getAddressKey(TOKEN_B)].sort()) + }) +}) diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts new file mode 100644 index 00000000000..bfec7e8a493 --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts @@ -0,0 +1,18 @@ +import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' +import { useUserAddedTokens } from '@cowprotocol/tokens' + +import { useStableStringArray } from './useStableStringArray' + +/** + * Returns the sorted, normalized addresses of user-imported tokens for the + * given chain. Identity is stable across renders unless the set of addresses + * changes. + */ +export function useCustomTokensForChain(chainId: SupportedChainId): string[] { + const userAddedTokens = useUserAddedTokens() + const computed = userAddedTokens + .filter((token) => token.chainId === chainId) + .map((token) => getAddressKey(token.address)) + .sort() + return useStableStringArray(computed) +} diff --git a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts new file mode 100644 index 00000000000..d2f2ca124f9 --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts @@ -0,0 +1,16 @@ +import { useListsEnabledState } from '@cowprotocol/tokens' + +import { useStableStringArray } from './useStableStringArray' + +/** + * Returns the sorted URLs of token lists currently enabled by the user. + * Identity is stable across renders unless the set of enabled URLs changes. + */ +export function useEnabledTokensListsUrls(): string[] { + const enabledState = useListsEnabledState() + const computed = Object.entries(enabledState) + .filter(([, enabled]) => enabled === true) + .map(([source]) => source) + .sort() + return useStableStringArray(computed) +} diff --git a/libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx b/libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx new file mode 100644 index 00000000000..484fd567c6d --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx @@ -0,0 +1,62 @@ +import { renderHook } from '@testing-library/react' + +import { useStableStringArray } from './useStableStringArray' + +describe('useStableStringArray', () => { + it('returns the same reference across renders when contents are element-wise equal', () => { + const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { + initialProps: { value: ['a', 'b', 'c'] }, + }) + const first = result.current + + rerender({ value: ['a', 'b', 'c'] }) + + expect(result.current).toBe(first) + }) + + it('returns a new reference when an element changes', () => { + const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { + initialProps: { value: ['a', 'b'] }, + }) + const first = result.current + + rerender({ value: ['a', 'c'] }) + + expect(result.current).not.toBe(first) + expect(result.current).toEqual(['a', 'c']) + }) + + it('returns a new reference when the array length changes', () => { + const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { + initialProps: { value: ['a', 'b'] }, + }) + const first = result.current + + rerender({ value: ['a', 'b', 'c'] }) + + expect(result.current).not.toBe(first) + expect(result.current).toEqual(['a', 'b', 'c']) + }) + + it('keeps a stable reference for empty arrays', () => { + const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { + initialProps: { value: [] as string[] }, + }) + const first = result.current + + rerender({ value: [] }) + + expect(result.current).toBe(first) + }) + + it('preserves element order — same elements in different order produce a new reference', () => { + const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { + initialProps: { value: ['a', 'b'] }, + }) + const first = result.current + + rerender({ value: ['b', 'a'] }) + + expect(result.current).not.toBe(first) + }) +}) diff --git a/libs/balances-and-allowances/src/hooks/useStableStringArray.ts b/libs/balances-and-allowances/src/hooks/useStableStringArray.ts new file mode 100644 index 00000000000..a602c9255e9 --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useStableStringArray.ts @@ -0,0 +1,28 @@ +import { useState } from 'react' + +/** + * Returns the previous array reference when the new value is element-wise + * equal — so downstream effects that depend on the array identity only re-run + * when the list contents actually change, not on every parent render. + * + * Uses the React-blessed "storing information from previous renders" pattern + * (useState + bail-out) instead of a ref so the value is read consistently + * within a render. + */ +export function useStableStringArray(value: string[]): string[] { + const [stable, setStable] = useState(value) + if (!shallowEqualStringArrays(stable, value)) { + setStable(value) + return value + } + return stable +} + +function shallowEqualStringArrays(a: readonly string[], b: readonly string[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} diff --git a/libs/balances-and-allowances/src/index.ts b/libs/balances-and-allowances/src/index.ts index a895da37148..8795fc0c23c 100644 --- a/libs/balances-and-allowances/src/index.ts +++ b/libs/balances-and-allowances/src/index.ts @@ -1,5 +1,6 @@ // Updater export { BalancesAndAllowancesUpdater } from './updaters/BalancesAndAllowancesUpdater' +export { BalancesWatcherUpdater } from './updaters/BalancesWatcherUpdater' export { TradeSpenderOverrideUpdater } from './updaters/TradeSpenderOverrideUpdater' export { PriorityTokensUpdater, PRIORITY_TOKENS_REFRESH_INTERVAL } from './updaters/PriorityTokensUpdater' diff --git a/libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx b/libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx new file mode 100644 index 00000000000..a99cb36d304 --- /dev/null +++ b/libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react' + +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { BalancesCacheUpdater } from './BalancesCacheUpdater' +import { BalancesResetUpdater } from './BalancesResetUpdater' + +import { useBalancesWatcherSession } from '../hooks/useBalancesWatcherSession' +import { useCustomTokensForChain } from '../hooks/useCustomTokensForChain' +import { useEnabledTokensListsUrls } from '../hooks/useEnabledTokensListsUrls' + +export interface BalancesWatcherUpdaterProps { + account: string | undefined + chainId: SupportedChainId +} + +const EMPTY_EXCLUDED_TOKENS: Set = new Set() + +/** + * Watcher-mode peer of `BalancesAndAllowancesUpdater`. Drives `balancesAtom` + * via the balances-watcher SSE stream and mounts the same reset/cache + * subtrees so the atom lifecycle (chain/account reset, localStorage + * hydration/persistence) stays intact when the LD flag is on. + */ +export function BalancesWatcherUpdater({ account, chainId }: BalancesWatcherUpdaterProps): ReactNode { + const tokensListsUrls = useEnabledTokensListsUrls() + const customTokens = useCustomTokensForChain(chainId) + + useBalancesWatcherSession({ account, chainId, tokensListsUrls, customTokens }) + + return ( + <> + + + + ) +} From ef2de4a680f57060de722eb09802eb312cfab64b Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 10 Jun 2026 22:13:49 +0400 Subject: [PATCH 05/13] fix: address review comments --- .github/workflows/ci.yml | 1 + .github/workflows/ipfs.yml | 1 + .github/workflows/vercel.yml | 1 + .../modules/affiliate/api/bffAffiliateApi.ts | 3 ++- .../src/balancesWatcher/createSession.ts | 5 ++--- .../subscribeToBalancesEvents.test.ts | 21 ++++++++++++++++-- .../subscribeToBalancesEvents.ts | 22 ++++++++----------- libs/common-utils/src/api-utils.ts | 11 ++-------- libs/common-utils/src/index.ts | 1 + libs/common-utils/src/json-utils.ts | 13 +++++++++++ libs/common-utils/src/url-utils.ts | 7 ++++++ 11 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 libs/common-utils/src/url-utils.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1e030d5b3b..46b11e89e4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ env: REACT_APP_GOOGLE_ANALYTICS_ID: ${{ secrets.REACT_APP_GOOGLE_ANALYTICS_ID }} REACT_APP_BLOCKNATIVE_API_KEY: ${{ secrets.REACT_APP_BLOCKNATIVE_API_KEY }} REACT_APP_BFF_BASE_URL: ${{ secrets.BFF_BASE_URL }} + REACT_APP_BALANCES_WATCHER_BASE_URL: ${{ secrets.BALANCES_WATCHER_BASE_URL }} REACT_APP_CMS_BASE_URL: ${{ secrets.CMS_BASE_URL }} NEXT_PUBLIC_CMS_BASE_URL: ${{ secrets.CMS_BASE_URL }} PACKAGE_READ_AUTH_TOKEN: ${{ secrets.PACKAGE_READ_AUTH_TOKEN }} diff --git a/.github/workflows/ipfs.yml b/.github/workflows/ipfs.yml index 54374a885ed..f7c0950af62 100644 --- a/.github/workflows/ipfs.yml +++ b/.github/workflows/ipfs.yml @@ -18,6 +18,7 @@ env: IPFS_DEPLOY_PINATA__API_KEY: ${{ secrets.REACT_APP_PINATA_API_KEY }} IPFS_DEPLOY_PINATA__SECRET_API_KEY: ${{ secrets.REACT_APP_PINATA_SECRET_API_KEY }} REACT_APP_BFF_BASE_URL: ${{ secrets.BFF_BASE_URL }} + REACT_APP_BALANCES_WATCHER_BASE_URL: ${{ secrets.BALANCES_WATCHER_BASE_URL }} REACT_APP_CMS_BASE_URL: ${{ secrets.CMS_BASE_URL }} NEXT_PUBLIC_CMS_BASE_URL: ${{ secrets.CMS_BASE_URL }} NODE_VERSION: lts/jod diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml index 4c0827654db..16d99d750b6 100644 --- a/.github/workflows/vercel.yml +++ b/.github/workflows/vercel.yml @@ -81,6 +81,7 @@ jobs: REACT_APP_SUBGRAPH_URL_BASE: ${{ secrets.REACT_APP_SUBGRAPH_URL_BASE }} REACT_APP_SUBGRAPH_URL_GNOSIS_CHAIN: ${{ secrets.REACT_APP_SUBGRAPH_URL_GNOSIS_CHAIN }} REACT_APP_BFF_BASE_URL: ${{ secrets.BFF_BASE_URL }} + REACT_APP_BALANCES_WATCHER_BASE_URL: ${{ secrets.BALANCES_WATCHER_BASE_URL }} REACT_APP_CMS_BASE_URL: ${{ secrets.CMS_BASE_URL }} NEXT_PUBLIC_CMS_BASE_URL: ${{ secrets.CMS_BASE_URL }} REACT_APP_NEAR_API_KEY: ${{ secrets.REACT_APP_NEAR_API_KEY }} diff --git a/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts b/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts index 2638f214c40..2a017e94ec3 100644 --- a/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts +++ b/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts @@ -6,6 +6,7 @@ import { parseJsonResponse, RetryableResponseError, STATUS_CODES_TO_RETRY, + stripTrailingSlash, unwrapOk, } from '@cowprotocol/common-utils' import type { ApiErrorPayload, FetchJsonResponse } from '@cowprotocol/common-utils' @@ -42,7 +43,7 @@ class BffAffiliateApi { */ constructor(baseUrl: string, timeoutMs: number = AFFILIATE_API_TIMEOUT_MS) { - this.baseUrl = baseUrl.replace(/\/$/, '') + this.baseUrl = stripTrailingSlash(baseUrl) this.timeoutMs = timeoutMs this.fetchRateLimited = fetchWithRateLimit({ rateLimit: { diff --git a/libs/balances-and-allowances/src/balancesWatcher/createSession.ts b/libs/balances-and-allowances/src/balancesWatcher/createSession.ts index 113cdaad012..8286ce3878d 100644 --- a/libs/balances-and-allowances/src/balancesWatcher/createSession.ts +++ b/libs/balances-and-allowances/src/balancesWatcher/createSession.ts @@ -1,5 +1,5 @@ import { BALANCES_WATCHER_BASE_URL } from '@cowprotocol/common-const' -import { fetchWithTimeout, JSON_HEADERS, parseJsonResponse } from '@cowprotocol/common-utils' +import { fetchWithTimeout, JSON_HEADERS, parseJsonResponse, stripTrailingSlash } from '@cowprotocol/common-utils' import type { SupportedChainId } from '@cowprotocol/cow-sdk' import { BalancesWatcherApiError, type BalancesWatcherErrorPayload, type CreateSessionRequest } from './types' @@ -21,8 +21,7 @@ export interface CreateSessionParams { * `subscribeToBalancesEvents` after this call resolves. */ export async function createBalancesWatcherSession(params: CreateSessionParams): Promise { - // Strip a trailing slash so the joined URL doesn't end up with `//`. - const baseUrl = (params.baseUrl ?? BALANCES_WATCHER_BASE_URL).replace(/\/$/, '') + const baseUrl = stripTrailingSlash(params.baseUrl ?? BALANCES_WATCHER_BASE_URL) const url = `${baseUrl}/${params.chainId}/sessions/${params.owner}` const response = await fetchWithTimeout(url, { diff --git a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts index 461560f797a..381c80018c7 100644 --- a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts +++ b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.test.ts @@ -66,7 +66,7 @@ function start(extra: Partial[0]> = chainId: SupportedChainId.MAINNET, owner: OWNER, baseUrl: BASE_URL, - EventSourceCtor: MockEventSource as unknown as typeof EventSource, + EventSourceConstructor: MockEventSource as unknown as typeof EventSource, ...cbs, ...extra, }) @@ -108,7 +108,7 @@ describe('subscribeToBalancesEvents', () => { expect(cbs.onError).toHaveBeenCalledTimes(1) const [error, terminal] = cbs.onError.mock.calls[0] expect(terminal).toBe(true) - expect((error as Error).message).toMatch(/missing `balances` field/) + expect((error as Error).message).toMatch(/missing or invalid `balances` field/) expect(source.closeCallCount).toBe(1) // Follow-up valid event must NOT reach onBalances (subscription is closed). @@ -116,6 +116,23 @@ describe('subscribeToBalancesEvents', () => { expect(cbs.onBalances).not.toHaveBeenCalled() }) + it.each([ + ['array', JSON.stringify({ balances: ['0xtoken1', '100'] })], + ['string primitive', JSON.stringify({ balances: 'something' })], + ['number primitive', JSON.stringify({ balances: 42 })], + ])('treats a balance_update with a non-object `balances` value (%s) as terminal corruption', (_label, raw) => { + const { source, cbs } = start() + + source.emit('balance_update', raw) + + expect(cbs.onBalances).not.toHaveBeenCalled() + expect(cbs.onError).toHaveBeenCalledTimes(1) + const [error, terminal] = cbs.onError.mock.calls[0] + expect(terminal).toBe(true) + expect((error as Error).message).toMatch(/missing or invalid `balances` field/) + expect(source.closeCallCount).toBe(1) + }) + it('treats invalid JSON in a balance_update as terminal corruption and closes the source', () => { const { source, cbs } = start() diff --git a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts index 569f3f9667d..680fcb285ff 100644 --- a/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts +++ b/libs/balances-and-allowances/src/balancesWatcher/subscribeToBalancesEvents.ts @@ -1,4 +1,5 @@ import { BALANCES_WATCHER_BASE_URL } from '@cowprotocol/common-const' +import { isRecord, stripTrailingSlash, tryParseJson } from '@cowprotocol/common-utils' import type { SupportedChainId } from '@cowprotocol/cow-sdk' import { @@ -40,21 +41,13 @@ export interface SubscribeToBalancesEventsParams { /** * Override EventSource constructor — for tests. */ - EventSourceCtor?: typeof EventSource -} - -function tryParseJson(input: string): T | undefined { - try { - return JSON.parse(input) as T - } catch { - return undefined - } + EventSourceConstructor?: typeof EventSource } export function subscribeToBalancesEvents(params: SubscribeToBalancesEventsParams): BalancesSubscription { - const baseUrl = (params.baseUrl ?? BALANCES_WATCHER_BASE_URL).replace(/\/$/, '') + const baseUrl = stripTrailingSlash(params.baseUrl ?? BALANCES_WATCHER_BASE_URL) const url = `${baseUrl}/sse/${params.chainId}/balances/${params.owner}` - const EventSourceConstructor = params.EventSourceCtor ?? globalThis.EventSource + const EventSourceConstructor = params.EventSourceConstructor ?? globalThis.EventSource if (!EventSourceConstructor) { throw new Error('EventSource is not available in this environment') @@ -81,8 +74,11 @@ export function subscribeToBalancesEvents(params: SubscribeToBalancesEventsParam terminate(new Error(`Failed to parse balance_update payload: ${event.data}`)) return } - if (!payload.balances) { - terminate(new Error('balance_update payload missing `balances` field')) + // Arrays/primitives in `balances` would silently corrupt the local map — + // reject anything that isn't a plain record. `isRecord` accepts arrays, + // hence the extra `Array.isArray` guard. + if (!isRecord(payload.balances) || Array.isArray(payload.balances)) { + terminate(new Error('balance_update payload missing or invalid `balances` field')) return } diff --git a/libs/common-utils/src/api-utils.ts b/libs/common-utils/src/api-utils.ts index bfc286b712f..3cbacbcc4f3 100644 --- a/libs/common-utils/src/api-utils.ts +++ b/libs/common-utils/src/api-utils.ts @@ -1,3 +1,4 @@ +import { tryParseJson } from './json-utils' import { getTimeoutAbortController } from './request' export const TIMEOUT_ERROR_MESSAGE = 'Request timed out. Please try again.' @@ -71,15 +72,7 @@ export async function fetchWithTimeout(input: RequestInfo | URL, options: FetchT export async function parseJsonResponse(response: Response): Promise> { const text = await response.text().catch(() => '') - let data: T | undefined - - if (text) { - try { - data = JSON.parse(text) as T - } catch { - data = undefined - } - } + const data = text ? tryParseJson(text) : undefined return { response, data, text } } diff --git a/libs/common-utils/src/index.ts b/libs/common-utils/src/index.ts index 0557a03c147..8a38f71a5aa 100644 --- a/libs/common-utils/src/index.ts +++ b/libs/common-utils/src/index.ts @@ -36,6 +36,7 @@ export * from './isFractionFalsy' export * from './isInjectedWidget' export * from './isIframe' export * from './json-utils' +export * from './url-utils' export * from './isSellOrder' export * from './isSupportedChainId' export * from './isValidTokenListSource' diff --git a/libs/common-utils/src/json-utils.ts b/libs/common-utils/src/json-utils.ts index ceb96a93625..56d747cc01a 100644 --- a/libs/common-utils/src/json-utils.ts +++ b/libs/common-utils/src/json-utils.ts @@ -4,6 +4,19 @@ export function isRecord(value: unknown): value is JsonRecord { return typeof value === 'object' && value !== null } +/** + * Best-effort JSON.parse: returns the parsed value on success, `undefined` on + * any parse error. Use when malformed input is recoverable (e.g. optional + * payload, fallback path) and the caller does not want to handle a throw. + */ +export function tryParseJson(text: string): T | undefined { + try { + return JSON.parse(text) as T + } catch { + return undefined + } +} + export function readNumberField(value: T | undefined | null, key: keyof T): number | undefined { if (!value) return undefined diff --git a/libs/common-utils/src/url-utils.ts b/libs/common-utils/src/url-utils.ts new file mode 100644 index 00000000000..c76dd453611 --- /dev/null +++ b/libs/common-utils/src/url-utils.ts @@ -0,0 +1,7 @@ +/** + * Removes a single trailing slash from the URL, leaving the rest of the path + * untouched. Safe to call on already-trimmed URLs. + */ +export function stripTrailingSlash(url: string): string { + return url.replace(/\/$/, '') +} From 12aec76f533b5dc1bdcf1785929fcfa0d4ec949d Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 10 Jun 2026 22:29:14 +0400 Subject: [PATCH 06/13] chore: comments --- .../src/hooks/useBalancesWatcherSession.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts index f892860e070..81ce3713d4f 100644 --- a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts +++ b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts @@ -26,10 +26,6 @@ export interface UseBalancesWatcherSessionParams { } /** - * Owns the (POST session → open SSE) lifecycle for the balances watcher and - * writes incoming balance updates into `balancesAtom`. Replaces the multicall - * pipeline when the `useBalancesWatcher` LD flag is on. - * * Lifecycle: a new session is created whenever account, chainId, or the set of * lists/custom tokens changes. The previous EventSource is closed and any * in-flight POST is invalidated via an epoch ref (the transport's @@ -44,12 +40,9 @@ export function useBalancesWatcherSession(params: UseBalancesWatcherSessionParam // The `tokensListsUrls` and `customTokens` arrays are expected to be // referentially stable across renders (callers memoize them). The session is - // re-created whenever any of the deps below change identity. useEffect(() => { if (!account) return if (!isEvmChain(chainId)) return - // Server rejects (400) when both arrays are empty — skip session creation - // until the user enables a list or imports a token. if (tokensListsUrls.length === 0 && customTokens.length === 0) return const epoch = ++epochRef.current From e9785cfcf2024d31651efc5f4572ed6f2cc06718 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 10 Jun 2026 22:36:57 +0400 Subject: [PATCH 07/13] refactor: replace epoch by cancel flag --- .../src/hooks/useBalancesWatcherSession.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts index 81ce3713d4f..f51d427247d 100644 --- a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts +++ b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.ts @@ -1,5 +1,5 @@ import { useSetAtom } from 'jotai' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { getAddressKey, isEvmChain, SupportedChainId } from '@cowprotocol/cow-sdk' @@ -36,16 +36,16 @@ export function useBalancesWatcherSession(params: UseBalancesWatcherSessionParam const { account, chainId, tokensListsUrls, customTokens } = params const setBalances = useSetAtom(balancesAtom) - const epochRef = useRef(0) - // The `tokensListsUrls` and `customTokens` arrays are expected to be - // referentially stable across renders (callers memoize them). The session is useEffect(() => { if (!account) return if (!isEvmChain(chainId)) return if (tokensListsUrls.length === 0 && customTokens.length === 0) return - const epoch = ++epochRef.current + // Each effect run owns its own `cancelled` flag; the cleanup flips this + // run's flag to true so any later `.then` / SSE callback short-circuits. + // A newer effect run gets a fresh `cancelled = false` and proceeds. + let cancelled = false let subscription: BalancesSubscription | undefined let isFirstEvent = true @@ -57,18 +57,18 @@ export function useBalancesWatcherSession(params: UseBalancesWatcherSessionParam body: { tokensListsUrls, customTokens }, }) .then(() => { - if (epoch !== epochRef.current) return + if (cancelled) return subscription = subscribeToBalancesEvents({ chainId, owner: account, onBalances: (balances) => { - if (epoch !== epochRef.current) return + if (cancelled) return setBalances((state) => writeBalancesUpdate(state, balances, chainId, isFirstEvent)) isFirstEvent = false }, onError: (error, terminal) => { - if (epoch !== epochRef.current) return + if (cancelled) return // Non-terminal errors mean EventSource is reconnecting — the // transport recovers on its own. Only surface terminal errors. if (!terminal) return @@ -77,17 +77,13 @@ export function useBalancesWatcherSession(params: UseBalancesWatcherSessionParam }) }) .catch((error: unknown) => { - if (epoch !== epochRef.current) return + if (cancelled) return const message = error instanceof Error ? error.message : String(error) setBalances((state) => ({ ...state, error: message, isLoading: false })) }) return () => { - // Bumping the epoch invalidates any pending `.then` / SSE callback that - // resolves after teardown — `epoch` (captured) will no longer match - // `epochRef.current`. Mutation in the cleanup is intentional here. - // eslint-disable-next-line react-hooks/exhaustive-deps - epochRef.current++ + cancelled = true subscription?.close() } }, [account, chainId, tokensListsUrls, customTokens, setBalances]) From ffc4819b7768a26802c7304e7848141ae50667e4 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Tue, 16 Jun 2026 23:08:52 +0400 Subject: [PATCH 08/13] feat: remove custom tokens comparator --- .../hooks/useCustomTokensForChain.test.tsx | 29 ------------------- .../src/hooks/useCustomTokensForChain.ts | 8 ++--- 2 files changed, 2 insertions(+), 35 deletions(-) diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx index 851bab4e54a..e313c45762e 100644 --- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx @@ -68,33 +68,4 @@ describe('useCustomTokensForChain', () => { const expected = [TOKEN_A, TOKEN_B, TOKEN_C].map(getAddressKey).sort() expect(result.current).toEqual(expected) }) - - it('preserves array identity across renders when the resulting set is equal', () => { - mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) - - const { result, rerender } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) - const first = result.current - - // Different array reference, same content. - mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) - rerender() - - expect(result.current).toBe(first) - }) - - it('emits a new array identity when the set of addresses changes', () => { - mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) - - const { result, rerender } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) - const first = result.current - - mockTokens([ - { chainId: SupportedChainId.MAINNET, address: TOKEN_A }, - { chainId: SupportedChainId.MAINNET, address: TOKEN_B }, - ]) - rerender() - - expect(result.current).not.toBe(first) - expect(result.current).toEqual([getAddressKey(TOKEN_A), getAddressKey(TOKEN_B)].sort()) - }) }) diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts index bfec7e8a493..a7eebdd94c9 100644 --- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts @@ -1,18 +1,14 @@ import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' import { useUserAddedTokens } from '@cowprotocol/tokens' -import { useStableStringArray } from './useStableStringArray' - /** * Returns the sorted, normalized addresses of user-imported tokens for the - * given chain. Identity is stable across renders unless the set of addresses - * changes. + * given chain. */ export function useCustomTokensForChain(chainId: SupportedChainId): string[] { const userAddedTokens = useUserAddedTokens() - const computed = userAddedTokens + return userAddedTokens .filter((token) => token.chainId === chainId) .map((token) => getAddressKey(token.address)) .sort() - return useStableStringArray(computed) } From f339215dde0fa460c3334b1ea98909e6b1632338 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 17 Jun 2026 00:13:19 +0400 Subject: [PATCH 09/13] refactor: useMemo --- .../src/hooks/useCustomTokensForChain.ts | 18 ++++-- .../src/hooks/useEnabledTokensListsUrls.ts | 22 +++---- .../src/hooks/useStableStringArray.test.tsx | 62 ------------------- .../src/hooks/useStableStringArray.ts | 28 --------- 4 files changed, 24 insertions(+), 106 deletions(-) delete mode 100644 libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx delete mode 100644 libs/balances-and-allowances/src/hooks/useStableStringArray.ts diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts index a7eebdd94c9..eaa42636abd 100644 --- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts @@ -1,14 +1,22 @@ +import { useMemo } from 'react' + import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' import { useUserAddedTokens } from '@cowprotocol/tokens' /** * Returns the sorted, normalized addresses of user-imported tokens for the - * given chain. + * given chain. The reference is stable as long as the source atom does not + * recompute. */ export function useCustomTokensForChain(chainId: SupportedChainId): string[] { const userAddedTokens = useUserAddedTokens() - return userAddedTokens - .filter((token) => token.chainId === chainId) - .map((token) => getAddressKey(token.address)) - .sort() + + return useMemo( + () => + userAddedTokens + .filter((token) => token.chainId === chainId) + .map((token) => getAddressKey(token.address)) + .sort(), + [userAddedTokens, chainId], + ) } diff --git a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts index d2f2ca124f9..db60310cbcb 100644 --- a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts +++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts @@ -1,16 +1,16 @@ -import { useListsEnabledState } from '@cowprotocol/tokens' +import { useMemo } from 'react' -import { useStableStringArray } from './useStableStringArray' +import { useListsEnabledState } from '@cowprotocol/tokens' -/** - * Returns the sorted URLs of token lists currently enabled by the user. - * Identity is stable across renders unless the set of enabled URLs changes. - */ export function useEnabledTokensListsUrls(): string[] { const enabledState = useListsEnabledState() - const computed = Object.entries(enabledState) - .filter(([, enabled]) => enabled === true) - .map(([source]) => source) - .sort() - return useStableStringArray(computed) + + return useMemo( + () => + Object.entries(enabledState) + .filter(([, enabled]) => enabled === true) + .map(([source]) => source) + .sort(), + [enabledState], + ) } diff --git a/libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx b/libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx deleted file mode 100644 index 484fd567c6d..00000000000 --- a/libs/balances-and-allowances/src/hooks/useStableStringArray.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { renderHook } from '@testing-library/react' - -import { useStableStringArray } from './useStableStringArray' - -describe('useStableStringArray', () => { - it('returns the same reference across renders when contents are element-wise equal', () => { - const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { - initialProps: { value: ['a', 'b', 'c'] }, - }) - const first = result.current - - rerender({ value: ['a', 'b', 'c'] }) - - expect(result.current).toBe(first) - }) - - it('returns a new reference when an element changes', () => { - const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { - initialProps: { value: ['a', 'b'] }, - }) - const first = result.current - - rerender({ value: ['a', 'c'] }) - - expect(result.current).not.toBe(first) - expect(result.current).toEqual(['a', 'c']) - }) - - it('returns a new reference when the array length changes', () => { - const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { - initialProps: { value: ['a', 'b'] }, - }) - const first = result.current - - rerender({ value: ['a', 'b', 'c'] }) - - expect(result.current).not.toBe(first) - expect(result.current).toEqual(['a', 'b', 'c']) - }) - - it('keeps a stable reference for empty arrays', () => { - const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { - initialProps: { value: [] as string[] }, - }) - const first = result.current - - rerender({ value: [] }) - - expect(result.current).toBe(first) - }) - - it('preserves element order — same elements in different order produce a new reference', () => { - const { result, rerender } = renderHook(({ value }: { value: string[] }) => useStableStringArray(value), { - initialProps: { value: ['a', 'b'] }, - }) - const first = result.current - - rerender({ value: ['b', 'a'] }) - - expect(result.current).not.toBe(first) - }) -}) diff --git a/libs/balances-and-allowances/src/hooks/useStableStringArray.ts b/libs/balances-and-allowances/src/hooks/useStableStringArray.ts deleted file mode 100644 index a602c9255e9..00000000000 --- a/libs/balances-and-allowances/src/hooks/useStableStringArray.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react' - -/** - * Returns the previous array reference when the new value is element-wise - * equal — so downstream effects that depend on the array identity only re-run - * when the list contents actually change, not on every parent render. - * - * Uses the React-blessed "storing information from previous renders" pattern - * (useState + bail-out) instead of a ref so the value is read consistently - * within a render. - */ -export function useStableStringArray(value: string[]): string[] { - const [stable, setStable] = useState(value) - if (!shallowEqualStringArrays(stable, value)) { - setStable(value) - return value - } - return stable -} - -function shallowEqualStringArrays(a: readonly string[], b: readonly string[]): boolean { - if (a === b) return true - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false - } - return true -} From 1febce9635d409134ade495f9ec6b132c17a71b2 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 17 Jun 2026 00:26:56 +0400 Subject: [PATCH 10/13] feat: add eth tracking --- ...onPriorityBalancesAndAllowancesUpdater.tsx | 4 +- .../src/updaters/BalancesWatcherUpdater.tsx | 2 + .../NativeTokenBalanceUpdater.test.tsx | 87 +++++++++++++++++++ .../updaters/NativeTokenBalanceUpdater.tsx | 32 +++++++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.test.tsx create mode 100644 libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.tsx diff --git a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx index 09636601c38..39d438f7357 100644 --- a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx @@ -22,7 +22,7 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const balancesContext = useBalancesContext() const balancesAccount = balancesContext.account || account - const { useBalancesWatcher: isBalancesWatcherEnabled } = useFeatureFlags() + const { useBalancesWatcher: isBwEnabled } = useFeatureFlags() const priorityTokenAddresses = usePriorityTokenAddresses() const priorityTokenAddressesAsArray = useMemo(() => { @@ -56,7 +56,7 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const refreshTrigger = useOrdersFilledEventsTrigger() - if (isBalancesWatcherEnabled) { + if (isBwEnabled) { return } diff --git a/libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx b/libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx index a99cb36d304..5be6fdeb0e8 100644 --- a/libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx +++ b/libs/balances-and-allowances/src/updaters/BalancesWatcherUpdater.tsx @@ -4,6 +4,7 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' import { BalancesCacheUpdater } from './BalancesCacheUpdater' import { BalancesResetUpdater } from './BalancesResetUpdater' +import { NativeTokenBalanceUpdater } from './NativeTokenBalanceUpdater' import { useBalancesWatcherSession } from '../hooks/useBalancesWatcherSession' import { useCustomTokensForChain } from '../hooks/useCustomTokensForChain' @@ -32,6 +33,7 @@ export function BalancesWatcherUpdater({ account, chainId }: BalancesWatcherUpda <> + ) } diff --git a/libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.test.tsx b/libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.test.tsx new file mode 100644 index 00000000000..9fb699da61b --- /dev/null +++ b/libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.test.tsx @@ -0,0 +1,87 @@ +import { Provider, useAtomValue } from 'jotai' +import { useHydrateAtoms } from 'jotai/utils' +import React, { ReactNode } from 'react' + +import { NATIVE_CURRENCIES } from '@cowprotocol/common-const' +import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' + +import { act, renderHook } from '@testing-library/react' + +import { NativeTokenBalanceUpdater } from './NativeTokenBalanceUpdater' + +import { useNativeTokenBalance } from '../hooks/useNativeTokenBalance' +import { balancesAtom, BalancesState, DEFAULT_BALANCES_STATE } from '../state/balancesAtom' + +jest.mock('../hooks/useNativeTokenBalance', () => ({ + useNativeTokenBalance: jest.fn(), +})) + +const mockUseNativeTokenBalance = jest.requireMock<{ useNativeTokenBalance: jest.Mock }>( + '../hooks/useNativeTokenBalance', +).useNativeTokenBalance + +const ACCOUNT = '0x1234567890123456789012345678901234567890' +const MAINNET_NATIVE_KEY = getAddressKey(NATIVE_CURRENCIES[SupportedChainId.MAINNET].address) + +function HydrateAtoms({ children }: { children: ReactNode }): ReactNode { + useHydrateAtoms([[balancesAtom, DEFAULT_BALANCES_STATE]]) + return <>{children} +} + +function Wrapper({ children }: { children: ReactNode }): ReactNode { + return ( + + {children} + + ) +} + +function renderUpdater(account: string | undefined, chainId: SupportedChainId): { result: { current: BalancesState } } { + return renderHook( + () => { + NativeTokenBalanceUpdater({ account, chainId }) + return useAtomValue(balancesAtom) + }, + { wrapper: Wrapper }, + ) +} + +describe('NativeTokenBalanceUpdater', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseNativeTokenBalance.mockReturnValue({ data: undefined }) + }) + + it('does not write to balancesAtom while the wagmi query has no data yet', () => { + const { result } = renderUpdater(ACCOUNT, SupportedChainId.MAINNET) + expect(result.current.values[MAINNET_NATIVE_KEY]).toBeUndefined() + }) + + it('writes the native balance into balancesAtom under the chain native address key', () => { + mockUseNativeTokenBalance.mockReturnValue({ data: { value: 1234567890123456789n } }) + + const { result } = renderUpdater(ACCOUNT, SupportedChainId.MAINNET) + + expect(result.current.values[MAINNET_NATIVE_KEY]).toBe(1234567890123456789n) + }) + + it('forwards account and chainId to useNativeTokenBalance', () => { + renderUpdater(ACCOUNT, SupportedChainId.ARBITRUM_ONE) + + expect(useNativeTokenBalance).toHaveBeenCalledWith(ACCOUNT, SupportedChainId.ARBITRUM_ONE) + }) + + it('updates the stored balance when the wagmi query emits a new value', () => { + mockUseNativeTokenBalance.mockReturnValue({ data: { value: 100n } }) + + const { result, rerender } = renderUpdater(ACCOUNT, SupportedChainId.MAINNET) + expect(result.current.values[MAINNET_NATIVE_KEY]).toBe(100n) + + act(() => { + mockUseNativeTokenBalance.mockReturnValue({ data: { value: 500n } }) + rerender() + }) + + expect(result.current.values[MAINNET_NATIVE_KEY]).toBe(500n) + }) +}) diff --git a/libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.tsx b/libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.tsx new file mode 100644 index 00000000000..1862d3bf336 --- /dev/null +++ b/libs/balances-and-allowances/src/updaters/NativeTokenBalanceUpdater.tsx @@ -0,0 +1,32 @@ +import { useEffect } from 'react' + +import { NATIVE_CURRENCIES } from '@cowprotocol/common-const' +import type { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { useNativeTokenBalance } from '../hooks/useNativeTokenBalance' +import { useUpdateTokenBalance } from '../hooks/useUpdateTokenBalance' + +export interface NativeTokenBalanceUpdaterProps { + account: string | undefined + chainId: SupportedChainId +} + +/** + * Writes the connected wallet's native token balance into `balancesAtom`. + * The balances-watcher SSE stream does not emit native amounts, so we keep + * polling via wagmi's `eth_getBalance` (single RPC call, no multicall). + */ +export function NativeTokenBalanceUpdater({ account, chainId }: NativeTokenBalanceUpdaterProps): null { + const updateTokenBalance = useUpdateTokenBalance() + const { data: nativeTokenBalance } = useNativeTokenBalance(account, chainId) + + useEffect(() => { + const nativeToken = NATIVE_CURRENCIES[chainId] + + if (nativeToken && nativeTokenBalance) { + updateTokenBalance(nativeToken.address, nativeTokenBalance.value) + } + }, [nativeTokenBalance, chainId, updateTokenBalance]) + + return null +} From ac9903d58b6d3af0050c31d4f484a7bf0b194654 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 17 Jun 2026 00:57:00 +0400 Subject: [PATCH 11/13] fix: change ff name --- .../updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx index 39d438f7357..1c4ce28940e 100644 --- a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx @@ -22,7 +22,7 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const balancesContext = useBalancesContext() const balancesAccount = balancesContext.account || account - const { useBalancesWatcher: isBwEnabled } = useFeatureFlags() + const { isBwEnabled } = useFeatureFlags() const priorityTokenAddresses = usePriorityTokenAddresses() const priorityTokenAddressesAsArray = useMemo(() => { From 2a532c660a8c2bc2a6d669618530a6a05a1e93bd Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 17 Jun 2026 01:55:14 +0400 Subject: [PATCH 12/13] fix: skip non-evm networks --- .../updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx index 1c4ce28940e..e95bf2b5601 100644 --- a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/CommonPriorityBalancesAndAllowancesUpdater.tsx @@ -7,6 +7,7 @@ import { PriorityTokensUpdater, } from '@cowprotocol/balances-and-allowances' import { useFeatureFlags } from '@cowprotocol/common-hooks' +import { isNonEvmChain } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' import { useBalancesContext } from 'entities/balancesContext/useBalancesContext' @@ -56,7 +57,7 @@ export function CommonPriorityBalancesAndAllowancesUpdater(): ReactNode { const refreshTrigger = useOrdersFilledEventsTrigger() - if (isBwEnabled) { + if (isBwEnabled && !isNonEvmChain(sourceChainId)) { return } From f1d0cd46116d77a700b16141fc425eb9c14bf440 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 17 Jun 2026 01:59:23 +0400 Subject: [PATCH 13/13] fix: remove toLowerCase --- .../src/hooks/useBalancesWatcherSession.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx index 31b651effd5..96a11cc2f8c 100644 --- a/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx +++ b/libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.test.tsx @@ -123,7 +123,7 @@ describe('useBalancesWatcherSession', () => { const session = deferred() mockCreateSession.mockReturnValueOnce(session.promise) - renderSession(makeParams({ customTokens: [TOKEN_A.toLowerCase()] })) + renderSession(makeParams({ customTokens: [getAddressKey(TOKEN_A)] })) expect(mockCreateSession).toHaveBeenCalledTimes(1) expect(mockCreateSession).toHaveBeenCalledWith({ @@ -131,7 +131,7 @@ describe('useBalancesWatcherSession', () => { owner: ACCOUNT, body: { tokensListsUrls: ['https://example.com/tokens.json'], - customTokens: [TOKEN_A.toLowerCase()], + customTokens: [getAddressKey(TOKEN_A)], }, }) expect(mockSubscribe).not.toHaveBeenCalled()