Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions apps/cow-fi/util/markdownHtmlImages.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isHttpUrl } from '@cowprotocol/common-utils/safeLink'

interface AllowedHtmlImage {
src: string
alt: string
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isHttpUrl } from '@cowprotocol/common-utils'

export interface TrustedNotificationLink {
href: string
target: '_blank' | '_parent'
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,44 @@
import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk'
import { useUserAddedTokens } from '@cowprotocol/tokens'
import { useUserAddedTokens, useVirtualLists } from '@cowprotocol/tokens'

import { renderHook } from '@testing-library/react'

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 }

function mockTokens(tokens: MinimalToken[]): void {
useUserAddedTokensMock.mockReturnValue(tokens as unknown as ReturnType<typeof useUserAddedTokens>)
}

function mockVirtualLists(listsBySource: Record<string, MinimalToken[]>): void {
const state = Object.fromEntries(
Object.entries(listsBySource).map(([source, tokens]) => [source, { source, list: { tokens } }]),
)

useVirtualListsMock.mockReturnValue(state as unknown as ReturnType<typeof useVirtualLists>)
}

describe('useCustomTokensForChain', () => {
beforeEach(() => {
useUserAddedTokensMock.mockReset()
useVirtualListsMock.mockReset()
mockVirtualLists({})
})

it('returns an empty array when no user-added tokens exist', () => {
Expand All @@ -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', () => {
Expand All @@ -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())
})
})
31 changes: 23 additions & 8 deletions libs/balances-and-allowances/src/hooks/useCustomTokensForChain.ts
Original file line number Diff line number Diff line change
@@ -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<AddressKey>()

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])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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<string, boolean>): void {
useListsEnabledStateMock.mockReturnValue(state as unknown as ReturnType<typeof useListsEnabledState>)
}

function mockVirtualListSources(sources: string[]): void {
const state = Object.fromEntries(sources.map((source) => [source, { source }]))

useVirtualListsMock.mockReturnValue(state as unknown as ReturnType<typeof useVirtualLists>)
}

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'])
})
Comment thread
limitofzero marked this conversation as resolved.

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'])
})
})
Original file line number Diff line number Diff line change
@@ -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],
)
}
11 changes: 11 additions & 0 deletions libs/common-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
"import": "./src/json-utils.ts",
"require": "./src/json-utils.ts",
"default": "./src/json-utils.ts"
},
"./safeLink": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great solution!
I think we should do that more often to control bundles better

"types": "./src/safeLink.ts",
"import": "./src/safeLink.ts",
"require": "./src/safeLink.ts",
"default": "./src/safeLink.ts"
}
},
"publishConfig": {
Expand All @@ -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"
}
}
},
Expand Down
13 changes: 12 additions & 1 deletion libs/common-utils/src/safeLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading