From ae141474a653e05a30db754f80368a56ae7fd0b6 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 12 Aug 2026 17:43:12 +0400 Subject: [PATCH 1/5] fix: handle widgetCustomTokens record from token lists --- apps/cow-fi/util/markdownHtmlImages.ts | 9 +-- .../utils/getTrustedNotificationLink.ts | 5 +- .../hooks/useEnabledTokensListsUrls.test.ts | 64 +++++++++++++++++++ .../src/hooks/useEnabledTokensListsUrls.ts | 3 +- libs/common-utils/package.json | 11 ++++ libs/common-utils/src/safeLink.ts | 10 +++ 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts diff --git a/apps/cow-fi/util/markdownHtmlImages.ts b/apps/cow-fi/util/markdownHtmlImages.ts index edbfcb5967a..a4f9a750ac6 100644 --- a/apps/cow-fi/util/markdownHtmlImages.ts +++ b/apps/cow-fi/util/markdownHtmlImages.ts @@ -1,3 +1,5 @@ +import { isHttpUrl } from '@cowprotocol/common-utils/safeLink' + interface AllowedHtmlImage { src: string alt: string @@ -191,12 +193,7 @@ function isSafeImageSrc(src: string): boolean { if (ASCII_CONTROL_CHARACTERS_REGEXP.test(src)) return false if (!URL_SCHEME_REGEXP.test(src)) return true - try { - const parsedUrl = new URL(src) - return parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:' - } catch { - return false - } + return isHttpUrl(src) } function removeHtmlComments(html: string): string { diff --git a/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts b/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts index ffd7793b487..d0707798a7c 100644 --- a/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts +++ b/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts @@ -1,3 +1,5 @@ +import { isHttpUrl } from '@cowprotocol/common-utils' + export interface TrustedNotificationLink { href: string target: '_blank' | '_parent' @@ -24,9 +26,8 @@ export function getTrustedNotificationLink(url: string | null | undefined): Trus try { const parsedUrl = new URL(trimmedUrl) - const isHttpUrl = parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' - if (!isHttpUrl) { + if (!isHttpUrl(trimmedUrl)) { return null } diff --git a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts new file mode 100644 index 00000000000..8c5cac4a47c --- /dev/null +++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts @@ -0,0 +1,64 @@ +import { useListsEnabledState } from '@cowprotocol/tokens' + +import { renderHook } from '@testing-library/react' + +import { useEnabledTokensListsUrls } from './useEnabledTokensListsUrls' + +jest.mock('@cowprotocol/tokens', () => ({ + useListsEnabledState: jest.fn(), +})) + +const useListsEnabledStateMock = jest.requireMock<{ useListsEnabledState: jest.Mock }>( + '@cowprotocol/tokens', +).useListsEnabledState + +function mockEnabledState(state: Record): void { + useListsEnabledStateMock.mockReturnValue(state as unknown as ReturnType) +} + +describe('useEnabledTokensListsUrls', () => { + beforeEach(() => { + useListsEnabledStateMock.mockReset() + }) + + it('returns an empty array when no lists are enabled', () => { + mockEnabledState({}) + + const { result } = renderHook(() => useEnabledTokensListsUrls()) + + expect(result.current).toEqual([]) + }) + + it('excludes disabled list urls', () => { + mockEnabledState({ + 'https://example.com/list-a.json': true, + 'https://example.com/list-b.json': false, + }) + + const { result } = renderHook(() => useEnabledTokensListsUrls()) + + expect(result.current).toEqual(['https://example.com/list-a.json']) + }) + + it('excludes non-http(s) sources such as virtual widget list ids', () => { + mockEnabledState({ + 'https://example.com/list-a.json': true, + widgetCustomTokens: true, + }) + + const { result } = renderHook(() => useEnabledTokensListsUrls()) + + expect(result.current).toEqual(['https://example.com/list-a.json']) + }) + + it('returns enabled list urls sorted alphabetically', () => { + mockEnabledState({ + 'https://example.com/z-list.json': true, + 'https://example.com/a-list.json': true, + }) + + const { result } = renderHook(() => useEnabledTokensListsUrls()) + + expect(result.current).toEqual(['https://example.com/a-list.json', 'https://example.com/z-list.json']) + }) +}) diff --git a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts index db60310cbcb..68613718414 100644 --- a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts +++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' +import { isHttpUrl } from '@cowprotocol/common-utils' import { useListsEnabledState } from '@cowprotocol/tokens' export function useEnabledTokensListsUrls(): string[] { @@ -8,7 +9,7 @@ export function useEnabledTokensListsUrls(): string[] { return useMemo( () => Object.entries(enabledState) - .filter(([, enabled]) => enabled === true) + .filter(([source, enabled]) => enabled === true && isHttpUrl(source)) .map(([source]) => source) .sort(), [enabledState], diff --git a/libs/common-utils/package.json b/libs/common-utils/package.json index 73fedbd1fa2..0342de6f615 100644 --- a/libs/common-utils/package.json +++ b/libs/common-utils/package.json @@ -16,6 +16,12 @@ "import": "./src/json-utils.ts", "require": "./src/json-utils.ts", "default": "./src/json-utils.ts" + }, + "./safeLink": { + "types": "./src/safeLink.ts", + "import": "./src/safeLink.ts", + "require": "./src/safeLink.ts", + "default": "./src/safeLink.ts" } }, "publishConfig": { @@ -31,6 +37,11 @@ "types": "./json-utils.d.ts", "import": "./json-utils.mjs", "require": "./json-utils.js" + }, + "./safeLink": { + "types": "./safeLink.d.ts", + "import": "./safeLink.mjs", + "require": "./safeLink.js" } } }, diff --git a/libs/common-utils/src/safeLink.ts b/libs/common-utils/src/safeLink.ts index 2c29ab18e64..68f18340cdc 100644 --- a/libs/common-utils/src/safeLink.ts +++ b/libs/common-utils/src/safeLink.ts @@ -43,6 +43,16 @@ export function getSafeSameOriginOrAbsoluteUrl( } } +export function isHttpUrl(url: string): boolean { + try { + const parsedUrl = new URL(url) + + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' + } catch { + return false + } +} + function isAllowedHttpUrl(url: URL): boolean { if (url.username || url.password) return false if (url.protocol === 'https:') return true From 18a0011325087bf5115afc52e44c318ae5090432 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Wed, 12 Aug 2026 17:48:09 +0400 Subject: [PATCH 2/5] refactor: accept URL for check --- .../notifications/utils/getTrustedNotificationLink.ts | 2 +- libs/common-utils/src/safeLink.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts b/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts index d0707798a7c..c9d6d2c5910 100644 --- a/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts +++ b/apps/cowswap-frontend/src/modules/notifications/utils/getTrustedNotificationLink.ts @@ -27,7 +27,7 @@ export function getTrustedNotificationLink(url: string | null | undefined): Trus try { const parsedUrl = new URL(trimmedUrl) - if (!isHttpUrl(trimmedUrl)) { + if (!isHttpUrl(parsedUrl)) { return null } diff --git a/libs/common-utils/src/safeLink.ts b/libs/common-utils/src/safeLink.ts index 68f18340cdc..d7291da6463 100644 --- a/libs/common-utils/src/safeLink.ts +++ b/libs/common-utils/src/safeLink.ts @@ -43,9 +43,9 @@ export function getSafeSameOriginOrAbsoluteUrl( } } -export function isHttpUrl(url: string): boolean { +export function isHttpUrl(url: string | URL): boolean { try { - const parsedUrl = new URL(url) + const parsedUrl = url instanceof URL ? url : new URL(url) return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' } catch { @@ -55,9 +55,10 @@ export function isHttpUrl(url: string): boolean { function isAllowedHttpUrl(url: URL): boolean { if (url.username || url.password) return false + if (!isHttpUrl(url)) return false if (url.protocol === 'https:') return true - return url.protocol === 'http:' && isDevelopmentEnv() && isLocalDevHostname(url.hostname) + return isDevelopmentEnv() && isLocalDevHostname(url.hostname) } function isLocalDevHostname(hostname: string): boolean { From 63ccfef4472502f1e57d0f44075c6e44a37d366f Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Thu, 13 Aug 2026 02:22:08 +0400 Subject: [PATCH 3/5] feat: handle virtual token list --- .../hooks/useCustomTokensForChain.test.tsx | 43 ++++++++++++++++++- .../src/hooks/useCustomTokensForChain.ts | 24 ++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx index 73dfba2856c..dc15918b6c5 100644 --- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx @@ -1,5 +1,5 @@ import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' -import { useUserAddedTokens } from '@cowprotocol/tokens' +import { useUserAddedTokens, useVirtualLists } from '@cowprotocol/tokens' import { renderHook } from '@testing-library/react' @@ -7,15 +7,18 @@ import { useCustomTokensForChain } from './useCustomTokensForChain' jest.mock('@cowprotocol/tokens', () => ({ useUserAddedTokens: jest.fn(), + useVirtualLists: jest.fn(), })) const useUserAddedTokensMock = jest.requireMock<{ useUserAddedTokens: jest.Mock }>( '@cowprotocol/tokens', ).useUserAddedTokens +const useVirtualListsMock = jest.requireMock<{ useVirtualLists: jest.Mock }>('@cowprotocol/tokens').useVirtualLists const TOKEN_A = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' const TOKEN_B = '0xdAC17F958D2ee523a2206206994597C13D831ec7' const TOKEN_C = '0x6B175474E89094C44Da98b954EedeAC495271d0F' +const TOKEN_D = '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' type MinimalToken = { chainId: SupportedChainId; address: string } @@ -23,9 +26,19 @@ function mockTokens(tokens: MinimalToken[]): void { useUserAddedTokensMock.mockReturnValue(tokens as unknown as ReturnType) } +function mockVirtualLists(listsBySource: Record): void { + const state = Object.fromEntries( + Object.entries(listsBySource).map(([source, tokens]) => [source, { source, list: { tokens } }]), + ) + + useVirtualListsMock.mockReturnValue(state as unknown as ReturnType) +} + describe('useCustomTokensForChain', () => { beforeEach(() => { useUserAddedTokensMock.mockReset() + useVirtualListsMock.mockReset() + mockVirtualLists({}) }) it('returns an empty array when no user-added tokens exist', () => { @@ -55,4 +68,32 @@ describe('useCustomTokensForChain', () => { expect(result.current).toEqual([getAddressKey(TOKEN_A)]) }) + + it('includes tokens from widget virtual lists (e.g. widgetCustomTokens)', () => { + mockTokens([]) + mockVirtualLists({ + widgetCustomTokens: [ + { chainId: SupportedChainId.MAINNET, address: TOKEN_B }, + { chainId: SupportedChainId.ARBITRUM_ONE, address: TOKEN_C }, + ], + }) + + const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + + expect(result.current).toEqual([getAddressKey(TOKEN_B)]) + }) + + it('merges and dedupes user-added and virtual list tokens', () => { + mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) + mockVirtualLists({ + widgetCustomTokens: [ + { chainId: SupportedChainId.MAINNET, address: TOKEN_A }, + { chainId: SupportedChainId.MAINNET, address: TOKEN_D }, + ], + }) + + const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + + expect(result.current).toEqual([getAddressKey(TOKEN_A), getAddressKey(TOKEN_D)]) + }) }) diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts index ec63aff0dcf..884411db4fc 100644 --- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { AddressKey, getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' -import { useUserAddedTokens } from '@cowprotocol/tokens' +import { useUserAddedTokens, useVirtualLists } from '@cowprotocol/tokens' const EMPTY_CUSTOM_TOKENS: AddressKey[] = [] @@ -11,13 +11,25 @@ const EMPTY_CUSTOM_TOKENS: AddressKey[] = [] */ export function useCustomTokensForChain(chainId: SupportedChainId): AddressKey[] { const userAddedTokens = useUserAddedTokens() + const virtualLists = useVirtualLists() return useMemo(() => { - const addresses: AddressKey[] = [] + const addresses = new Set() + for (const token of userAddedTokens) { - if (token.chainId !== chainId) continue - addresses.push(getAddressKey(token.address)) + if (token.chainId === chainId) { + addresses.add(getAddressKey(token.address)) + } + } + + for (const list of Object.values(virtualLists)) { + for (const token of list.list.tokens) { + if (token.chainId === chainId) { + addresses.add(getAddressKey(token.address)) + } + } } - return addresses.length === 0 ? EMPTY_CUSTOM_TOKENS : addresses - }, [userAddedTokens, chainId]) + + return addresses.size === 0 ? EMPTY_CUSTOM_TOKENS : Array.from(addresses) + }, [userAddedTokens, virtualLists, chainId]) } From 162210df7625904c33a9c140828c4e5f78341a72 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Thu, 13 Aug 2026 14:14:44 +0400 Subject: [PATCH 4/5] fix: filter all virtualLists elements --- .../hooks/useEnabledTokensListsUrls.test.ts | 26 +++++++++++++++++-- .../src/hooks/useEnabledTokensListsUrls.ts | 11 +++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts index 8c5cac4a47c..da1b83c2d83 100644 --- a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts +++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts @@ -1,4 +1,4 @@ -import { useListsEnabledState } from '@cowprotocol/tokens' +import { useListsEnabledState, useVirtualLists } from '@cowprotocol/tokens' import { renderHook } from '@testing-library/react' @@ -6,19 +6,29 @@ import { useEnabledTokensListsUrls } from './useEnabledTokensListsUrls' jest.mock('@cowprotocol/tokens', () => ({ useListsEnabledState: jest.fn(), + useVirtualLists: jest.fn(), })) const useListsEnabledStateMock = jest.requireMock<{ useListsEnabledState: jest.Mock }>( '@cowprotocol/tokens', ).useListsEnabledState +const useVirtualListsMock = jest.requireMock<{ useVirtualLists: jest.Mock }>('@cowprotocol/tokens').useVirtualLists function mockEnabledState(state: Record): void { useListsEnabledStateMock.mockReturnValue(state as unknown as ReturnType) } +function mockVirtualListSources(sources: string[]): void { + const state = Object.fromEntries(sources.map((source) => [source, { source }])) + + useVirtualListsMock.mockReturnValue(state as unknown as ReturnType) +} + describe('useEnabledTokensListsUrls', () => { beforeEach(() => { useListsEnabledStateMock.mockReset() + useVirtualListsMock.mockReset() + mockVirtualListSources([]) }) it('returns an empty array when no lists are enabled', () => { @@ -40,17 +50,29 @@ describe('useEnabledTokensListsUrls', () => { expect(result.current).toEqual(['https://example.com/list-a.json']) }) - it('excludes non-http(s) sources such as virtual widget list ids', () => { + it('excludes virtual widget list sources (e.g. widgetCustomTokens)', () => { mockEnabledState({ 'https://example.com/list-a.json': true, widgetCustomTokens: true, }) + mockVirtualListSources(['widgetCustomTokens']) const { result } = renderHook(() => useEnabledTokensListsUrls()) expect(result.current).toEqual(['https://example.com/list-a.json']) }) + it('keeps non-http(s) sources that are not virtual lists (e.g. ipfs/ipns/ENS)', () => { + mockEnabledState({ + 'ipfs://QmSomeHash': true, + 'tokens.uniswap.eth': true, + }) + + const { result } = renderHook(() => useEnabledTokensListsUrls()) + + expect(result.current).toEqual(['ipfs://QmSomeHash', 'tokens.uniswap.eth']) + }) + it('returns enabled list urls sorted alphabetically', () => { mockEnabledState({ 'https://example.com/z-list.json': true, diff --git a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts index 68613718414..2345f5a5d0e 100644 --- a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts +++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts @@ -1,17 +1,20 @@ import { useMemo } from 'react' -import { isHttpUrl } from '@cowprotocol/common-utils' -import { useListsEnabledState } from '@cowprotocol/tokens' +import { useListsEnabledState, useVirtualLists } from '@cowprotocol/tokens' +// Virtual list sources (e.g. widget-provided `widgetCustomTokens`) are internal identifiers, not +// fetchable URLs. The BalancesWatcher session API only accepts real list URLs, so those must be +// filtered out. Their tokens are still tracked — see `useCustomTokensForChain`. export function useEnabledTokensListsUrls(): string[] { const enabledState = useListsEnabledState() + const virtualLists = useVirtualLists() return useMemo( () => Object.entries(enabledState) - .filter(([source, enabled]) => enabled === true && isHttpUrl(source)) + .filter(([source, enabled]) => enabled === true && !virtualLists[source]) .map(([source]) => source) .sort(), - [enabledState], + [enabledState, virtualLists], ) } From ed327ca0c91efb7f32828ee24f9f4b620247a2ef Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Thu, 13 Aug 2026 16:07:44 +0400 Subject: [PATCH 5/5] fix: sort array to preserve the order --- .../src/hooks/useCustomTokensForChain.test.tsx | 15 +++++++++++++-- .../src/hooks/useCustomTokensForChain.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx index dc15918b6c5..0392ecdcc58 100644 --- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx @@ -58,7 +58,7 @@ describe('useCustomTokensForChain', () => { const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) - expect(result.current).toEqual([getAddressKey(TOKEN_A), getAddressKey(TOKEN_C)]) + expect(result.current).toEqual([getAddressKey(TOKEN_C), getAddressKey(TOKEN_A)]) }) it('normalizes addresses via getAddressKey', () => { @@ -94,6 +94,17 @@ describe('useCustomTokensForChain', () => { const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) - expect(result.current).toEqual([getAddressKey(TOKEN_A), getAddressKey(TOKEN_D)]) + expect(result.current).toEqual([getAddressKey(TOKEN_D), getAddressKey(TOKEN_A)]) + }) + + it('returns addresses sorted regardless of source insertion order', () => { + mockTokens([{ chainId: SupportedChainId.MAINNET, address: TOKEN_A }]) + mockVirtualLists({ + widgetCustomTokens: [{ chainId: SupportedChainId.MAINNET, address: TOKEN_B }], + }) + + const { result } = renderHook(() => useCustomTokensForChain(SupportedChainId.MAINNET)) + + expect(result.current).toEqual([...result.current].sort()) }) }) diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts index 884411db4fc..8d0a1a700c5 100644 --- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts +++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts @@ -6,8 +6,11 @@ import { useUserAddedTokens, useVirtualLists } from '@cowprotocol/tokens' const EMPTY_CUSTOM_TOKENS: AddressKey[] = [] /** - * Normalized addresses of user-imported tokens for the given chain. The - * reference is stable as long as the source atom does not recompute. + * Normalized addresses of user-imported tokens and widget-provided custom tokens (virtual lists, + * e.g. `widgetCustomTokens`) for the given chain. Virtual lists aren't fetchable URLs (see + * `useEnabledTokensListsUrls`), so their tokens are tracked by address here instead. Sorted so the + * result is deterministic regardless of source insertion order, keeping `useStableStringList` + * (index-sensitive) from treating a reordered-but-unchanged set as a change. */ export function useCustomTokensForChain(chainId: SupportedChainId): AddressKey[] { const userAddedTokens = useUserAddedTokens() @@ -30,6 +33,6 @@ export function useCustomTokensForChain(chainId: SupportedChainId): AddressKey[] } } - return addresses.size === 0 ? EMPTY_CUSTOM_TOKENS : Array.from(addresses) + return addresses.size === 0 ? EMPTY_CUSTOM_TOKENS : Array.from(addresses).sort() }, [userAddedTokens, virtualLists, chainId]) }