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..c9d6d2c5910 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(parsedUrl)) {
return null
}
diff --git a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.test.tsx
index 73dfba2856c..0392ecdcc58 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', () => {
@@ -45,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', () => {
@@ -55,4 +68,43 @@ 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_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 ec63aff0dcf..8d0a1a700c5 100644
--- a/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts
+++ b/libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts
@@ -1,23 +1,38 @@
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[] = []
/**
- * 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()
+ 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).sort()
+ }, [userAddedTokens, virtualLists, chainId])
}
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..da1b83c2d83
--- /dev/null
+++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.test.ts
@@ -0,0 +1,86 @@
+import { useListsEnabledState, useVirtualLists } from '@cowprotocol/tokens'
+
+import { renderHook } from '@testing-library/react'
+
+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', () => {
+ 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 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,
+ '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..2345f5a5d0e 100644
--- a/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts
+++ b/libs/balances-and-allowances/src/hooks/useEnabledTokensListsUrls.ts
@@ -1,16 +1,20 @@
import { useMemo } from 'react'
-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(([, enabled]) => enabled === true)
+ .filter(([source, enabled]) => enabled === true && !virtualLists[source])
.map(([source]) => source)
.sort(),
- [enabledState],
+ [enabledState, virtualLists],
)
}
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..d7291da6463 100644
--- a/libs/common-utils/src/safeLink.ts
+++ b/libs/common-utils/src/safeLink.ts
@@ -43,11 +43,22 @@ export function getSafeSameOriginOrAbsoluteUrl(
}
}
+export function isHttpUrl(url: string | URL): boolean {
+ try {
+ const parsedUrl = url instanceof URL ? url : 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 (!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 {