Skip to content
Merged
8 changes: 7 additions & 1 deletion apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,9 @@ msgid "Set any limit price and time horizon"
msgstr "Set any limit price and time horizon"

#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTableRow.tsx
#: apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersConfirmModal/index.tsx
#: apps/cowswap-frontend/src/modules/swap/containers/SwapConfirmModal/index.tsx
#: apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx
msgid "token"
msgstr "token"

Expand Down Expand Up @@ -546,6 +548,11 @@ msgstr "Buy COW"
msgid "Reject"
msgstr "Reject"

#: apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersConfirmModal/index.tsx
#: apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx
msgid "Insufficient {inputSymbol} balance"
msgstr "Insufficient {inputSymbol} balance"

#: apps/cowswap-frontend/src/modules/orderProgressBar/helpers.ts
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/SurplusModal.tsx
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/SurplusModal.tsx
Expand Down Expand Up @@ -1956,7 +1963,6 @@ msgstr "Pick your own code or generate one. Once saved, it becomes permanently l
#~ msgstr "By clicking Confirm, you expressly represent and warrant that you are NOT:"

#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/ReceiptModal.modal.tsx
#: apps/cowswap-frontend/src/modules/trade/pure/TotalFeeRow/index.tsx
msgid "Total fee"
msgstr "Total fee"

Expand Down
119 changes: 119 additions & 0 deletions libs/balances-and-allowances/src/balancesWatcher/clientId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import ms from 'ms.macro'

const STORAGE_KEY = 'balances-watcher-client-id'
const TTL_MS = ms`1 day`

function store(id: string, createdAt: number): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, createdAt }))
}

// The module caches the client id at first call for the lifetime of the tab,
// so each test needs a fresh module instance to exercise the read/generate
// branch from a clean slate.
function loadModule(): typeof import('./clientId') {
let mod!: typeof import('./clientId')
jest.isolateModules(() => {
mod = require('./clientId')
})
return mod
}

describe('getBalancesWatcherClientId', () => {
beforeEach(() => {
jest.useFakeTimers()
})

afterEach(() => {
localStorage.clear()
jest.restoreAllMocks()
jest.useRealTimers()
})

it('returns the stored id when the entry is fresh', () => {
jest.setSystemTime(new Date('2026-07-08T12:00:00Z'))
store('existing-id', Date.now() - ms`1 hour`)

expect(loadModule().getBalancesWatcherClientId()).toBe('existing-id')
})

it('persists a freshly generated id so subsequent calls return the same value', () => {
jest.setSystemTime(new Date('2026-07-08T12:00:00Z'))
const { getBalancesWatcherClientId } = loadModule()

const first = getBalancesWatcherClientId()
const second = getBalancesWatcherClientId()

expect(first).toBe(second)
const raw = localStorage.getItem(STORAGE_KEY)
expect(raw).not.toBeNull()
expect(JSON.parse(raw as string)).toEqual({ id: first, createdAt: Date.now() })
})

it('rotates the id on the first call after the TTL has elapsed', () => {
jest.setSystemTime(new Date('2026-07-08T12:00:00Z'))
store('old-id', Date.now() - TTL_MS - 1)

const rotated = loadModule().getBalancesWatcherClientId()

expect(rotated).not.toBe('old-id')
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) as string)).toEqual({
id: rotated,
createdAt: Date.now(),
})
})

it('regenerates the id when the stored value is in the legacy plain-string format', () => {
// The previous version wrote the id directly (not wrapped in JSON). Any
// browser upgrading to this build must not keep leaking the pre-TTL id.
localStorage.setItem(STORAGE_KEY, 'legacy-plain-uuid')

const rotated = loadModule().getBalancesWatcherClientId()

expect(rotated).not.toBe('legacy-plain-uuid')
})

it('falls back to an in-memory id when localStorage.getItem throws and reuses it on the next call', () => {
jest.setSystemTime(new Date('2026-07-08T12:00:00Z'))
const getItemSpy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled')
})

const { getBalancesWatcherClientId } = loadModule()
const first = getBalancesWatcherClientId()
const second = getBalancesWatcherClientId()

expect(first).toBeTruthy()
expect(first).toBe(second)
expect(getItemSpy).toHaveBeenCalled()
})

it('keeps the same id within a tab even when the TTL boundary is crossed', () => {
// The POST /sessions + SSE /balances handshake must not straddle a TTL
// boundary — otherwise the backend session key (chainId, owner, clientId)
// would diverge between the two requests.
jest.setSystemTime(new Date('2026-07-08T12:00:00Z'))
const { getBalancesWatcherClientId } = loadModule()

const first = getBalancesWatcherClientId()
jest.advanceTimersByTime(TTL_MS + 1)
const second = getBalancesWatcherClientId()

expect(second).toBe(first)
})

it('keeps the id stable when localStorage.setItem throws mid-generation', () => {
// Storage may reject writes (quota / private mode) even when reads work.
// The generated id must still be cached in memory so both POST and SSE
// see the same value within the tab lifetime.
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota exceeded')
})

const { getBalancesWatcherClientId } = loadModule()
const first = getBalancesWatcherClientId()
const second = getBalancesWatcherClientId()

expect(first).toBeTruthy()
expect(first).toBe(second)
})
})
70 changes: 70 additions & 0 deletions libs/balances-and-allowances/src/balancesWatcher/clientId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import ms from 'ms.macro'

const STORAGE_KEY = 'balances-watcher-client-id'

// TTL is evaluated once per tab (on the first call), not per request. That way
// the POST /sessions and SSE /balances handshake always use the same id even
// if the TTL boundary is crossed mid-session; rotation happens on the next
// page load / tab open.
const CLIENT_ID_TTL_MS = ms`1 day`

interface StoredClientId {
id: string
createdAt: number
}

let cachedClientId: string | null = null

export function getBalancesWatcherClientId(): string {
if (cachedClientId) return cachedClientId

const stored = readStored()
if (stored && !isExpired(stored)) {
cachedClientId = stored.id
return stored.id
}

const fresh: StoredClientId = { id: crypto.randomUUID(), createdAt: Date.now() }
cachedClientId = fresh.id
tryPersist(fresh)
return fresh.id
}

function readStored(): StoredClientId | null {
try {
return parseStored(localStorage.getItem(STORAGE_KEY))
} catch {
return null
}
}

function tryPersist(entry: StoredClientId): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entry))
} catch {
// Storage disabled (private mode / quota / blocked). The in-memory cache
// above still keeps the id stable for this tab.
}
}

function parseStored(raw: string | null): StoredClientId | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as unknown
if (
typeof parsed === 'object' &&
parsed !== null &&
typeof (parsed as StoredClientId).id === 'string' &&
typeof (parsed as StoredClientId).createdAt === 'number'
) {
return parsed as StoredClientId
}
} catch {
return null
}
return null
}

function isExpired(entry: StoredClientId): boolean {
return Date.now() - entry.createdAt >= CLIENT_ID_TTL_MS
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BalancesWatcherApiError } from './types'

const OWNER = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const BASE_URL = 'https://watcher.example'
const CLIENT_ID = '00000000-0000-4000-8000-000000000000'

function mockFetchResponse(status: number, body: unknown): jest.SpyInstance {
const init: ResponseInit = {
Expand All @@ -16,8 +17,13 @@ function mockFetchResponse(status: number, body: unknown): jest.SpyInstance {
}

describe('createBalancesWatcherSession', () => {
beforeEach(() => {
localStorage.setItem('balances-watcher-client-id', JSON.stringify({ id: CLIENT_ID, createdAt: Date.now() }))
})

afterEach(() => {
jest.restoreAllMocks()
localStorage.clear()
})

it('POSTs to /{chainId}/sessions/{owner} with the request body and resolves on 2xx', async () => {
Expand All @@ -40,6 +46,21 @@ describe('createBalancesWatcherSession', () => {
})
})

it('sends the browser-scoped client id in the X-Client-Id header', async () => {
const fetchSpy = mockFetchResponse(200, '')

await createBalancesWatcherSession({
chainId: SupportedChainId.MAINNET,
owner: OWNER,
body: { tokensListsUrls: ['https://lists.example/uni.json'], customTokens: [] },
baseUrl: BASE_URL,
})

const [, calledInit] = fetchSpy.mock.calls[0] as [string, RequestInit]
const headers = calledInit.headers as Record<string, string>
expect(headers['X-Client-Id']).toBe(CLIENT_ID)
})

it('strips a trailing slash from baseUrl', async () => {
const fetchSpy = mockFetchResponse(200, '')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BALANCES_WATCHER_BASE_URL } from '@cowprotocol/common-const'
import { fetchWithTimeout, JSON_HEADERS, parseJsonResponse, stripTrailingSlash } from '@cowprotocol/common-utils'
import type { SupportedChainId } from '@cowprotocol/cow-sdk'

import { getBalancesWatcherClientId } from './clientId'
import { BalancesWatcherApiError, type BalancesWatcherErrorPayload, type CreateSessionRequest } from './types'

const DEFAULT_SESSION_TIMEOUT_MS = 10_000
Expand All @@ -26,7 +27,7 @@ export async function createBalancesWatcherSession(params: CreateSessionParams):

const response = await fetchWithTimeout(url, {
method: 'POST',
headers: JSON_HEADERS,
headers: { ...JSON_HEADERS, 'X-Client-Id': getBalancesWatcherClientId() },
body: JSON.stringify(params.body),
timeout: params.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BalancesWatcherStreamError } from './types'

const OWNER = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const BASE_URL = 'https://watcher.example'
const CLIENT_ID = '00000000-0000-4000-8000-000000000000'

interface MockEvent {
type: string
Expand Down Expand Up @@ -78,11 +79,16 @@ function start(extra: Partial<Parameters<typeof subscribeToBalancesEvents>[0]> =
describe('subscribeToBalancesEvents', () => {
beforeEach(() => {
MockEventSource.lastInstance = null
localStorage.setItem('balances-watcher-client-id', JSON.stringify({ id: CLIENT_ID, createdAt: Date.now() }))
})

it('connects to /sse/{chainId}/balances/{owner}', () => {
afterEach(() => {
localStorage.clear()
})

it('connects to /sse/{chainId}/balances/{owner} with client_id in the query string', () => {
const { source } = start()
expect(source.url).toBe(`${BASE_URL}/sse/1/balances/${OWNER}`)
expect(source.url).toBe(`${BASE_URL}/sse/1/balances/${OWNER}?client_id=${CLIENT_ID}`)
})

it('delivers every balance_update payload to onBalances in order', () => {
Expand Down Expand Up @@ -210,6 +216,6 @@ describe('subscribeToBalancesEvents', () => {

it('strips a trailing slash from baseUrl', () => {
const { source } = start({ baseUrl: `${BASE_URL}/` })
expect(source.url).toBe(`${BASE_URL}/sse/1/balances/${OWNER}`)
expect(source.url).toBe(`${BASE_URL}/sse/1/balances/${OWNER}?client_id=${CLIENT_ID}`)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { getBalancesWatcherClientId } from './clientId'
import {
type BalanceUpdateEvent,
type BalancesMap,
Expand Down Expand Up @@ -46,15 +47,19 @@ export interface SubscribeToBalancesEventsParams {

export function subscribeToBalancesEvents(params: SubscribeToBalancesEventsParams): BalancesSubscription {
const baseUrl = stripTrailingSlash(params.baseUrl ?? BALANCES_WATCHER_BASE_URL)
const url = `${baseUrl}/sse/${params.chainId}/balances/${params.owner}`
// `client_id` goes on the query string because the native `EventSource` API
// does not support custom request headers, so we cannot mirror the
// `X-Client-Id` header used on the POST side. The backend accepts both.
const url = new URL(`${baseUrl}/sse/${params.chainId}/balances/${params.owner}`)
url.searchParams.set('client_id', getBalancesWatcherClientId())
const EventSourceConstructor = params.EventSourceConstructor ?? globalThis.EventSource

if (!EventSourceConstructor) {
throw new Error('EventSource is not available in this environment')
}

let closed = false
const eventSource = new EventSourceConstructor(url)
const eventSource = new EventSourceConstructor(url.toString())

const terminate = (error: Error): void => {
closed = true
Expand Down
Loading