Skip to content
Open
Show file tree
Hide file tree
Changes from all 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