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 {