From 9e7added92275862de4543b5b43d35b7fc135ab8 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:33:41 +0100 Subject: [PATCH 1/4] fix: enforce widget token list restrictions --- .../hooks/useTokensToSelect.test.ts | 57 +++++++++++++-- .../tokensList/hooks/useTokensToSelect.ts | 13 ++-- .../state/tokensToSelectAtom.test.ts | 13 ++++ .../tokensList/state/tokensToSelectAtom.ts | 39 ++-------- .../tokenLists/tokenListsStateAtom.test.ts | 72 ++++++++++++++++++- .../state/tokenLists/tokenListsStateAtom.ts | 9 ++- 6 files changed, 157 insertions(+), 46 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts index 85d193d892d..114f3ef555f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts @@ -14,25 +14,20 @@ import { useChainsToSelect } from './useChainsToSelect' import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { useTokensToSelect } from './useTokensToSelect' -import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' - jest.mock('jotai', () => ({ ...jest.requireActual('jotai'), useAtomValue: jest.fn(), })) jest.mock('@cowprotocol/wallet', () => ({ - ...jest.requireActual('@cowprotocol/wallet'), useWalletInfo: jest.fn(), })) jest.mock('@cowprotocol/tokens', () => ({ - ...jest.requireActual('@cowprotocol/tokens'), useFavoriteTokens: jest.fn(), })) jest.mock('entities/bridgeProvider', () => ({ - ...jest.requireActual('entities/bridgeProvider'), useBridgeSupportedTokens: jest.fn(), })) @@ -44,6 +39,10 @@ jest.mock('./useChainsToSelect', () => ({ useChainsToSelect: jest.fn(), })) +jest.mock('../state/tokensToSelectAtom', () => ({ + tokensToSelectAtom: Symbol('tokensToSelectAtom'), +})) + const mockUseAtomValue = useAtomValue as jest.MockedFunction const mockUseWalletInfo = useWalletInfo as jest.MockedFunction const mockUseFavoriteTokens = useFavoriteTokens as jest.MockedFunction @@ -67,6 +66,38 @@ const lineaToken = { name: 'USD Coin', } as TokenWithLogo +const scopedFavoriteToken = { + address: '0x1111111111111111111111111111111111111111', + chainId: SupportedChainId.MAINNET, + decimals: 18, + symbol: 'AAPLx', + name: 'Scoped Favorite', +} as TokenWithLogo + +const leakedFavoriteToken = { + address: '0x2222222222222222222222222222222222222222', + chainId: SupportedChainId.MAINNET, + decimals: 18, + symbol: 'COW', + name: 'Leaked Favorite', +} as TokenWithLogo + +const DEFAULT_SELECT_TOKEN_WIDGET_STATE = { + open: false, + field: undefined, + selectedToken: undefined, + onSelectToken: undefined, + tokenToImport: undefined, + listToImport: undefined, + listToToggle: undefined, + selectedPoolAddress: undefined, + selectedTargetChainId: undefined, + tradeType: undefined, + forceOpen: false, + standalone: false, + displayLpTokenLists: false, +} as const + type WidgetState = ReturnType const createWidgetState = (override: Partial): WidgetState => { return { @@ -154,4 +185,20 @@ describe('useTokensToSelect', () => { sellChainId: SupportedChainId.ARBITRUM_ONE, }) }) + + it('filters favorite tokens to the currently selectable token set', () => { + mockUseAtomValue.mockReturnValue([scopedFavoriteToken]) + mockUseFavoriteTokens.mockReturnValue([scopedFavoriteToken, leakedFavoriteToken]) + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.INPUT, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useTokensToSelect()) + + expect(result.current.tokens).toEqual([scopedFavoriteToken]) + expect(result.current.favoriteTokens).toEqual([scopedFavoriteToken]) + }) }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts index 6178cc0240c..738927d9029 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts @@ -67,23 +67,26 @@ export function useTokensToSelect(): TokensToSelectContext { }, {}) }, [result]) + const visibleTokens = useMemo(() => { + return (areTokensFromBridge ? result?.tokens : allTokens) || EMPTY_TOKENS + }, [allTokens, areTokensFromBridge, result]) + return useMemo(() => { // In bridge mode, hide favorites until we know what's actually bridgeable for this chain pair. // This avoids selecting a favorite token and then getting it cleared by async validation. + const visibleTokenAddresses = new Set(visibleTokens.map((token) => getAddressKey(token.address))) const favoriteTokensToSelect = areTokensFromBridge && bridgeSupportedTokensMap === null ? EMPTY_TOKENS - : bridgeSupportedTokensMap - ? favoriteTokens.filter((token) => bridgeSupportedTokensMap[getAddressKey(token.address)]) - : favoriteTokens + : favoriteTokens.filter((token) => visibleTokenAddresses.has(getAddressKey(token.address))) return { isLoading: areTokensFromBridge ? isLoading : false, - tokens: (areTokensFromBridge ? result?.tokens : allTokens) || EMPTY_TOKENS, + tokens: visibleTokens, favoriteTokens: favoriteTokensToSelect, areTokensFromBridge, isRouteAvailable: result?.isRouteAvailable, bridgeSupportedTokensMap, } - }, [allTokens, bridgeSupportedTokensMap, isLoading, areTokensFromBridge, favoriteTokens, result]) + }, [bridgeSupportedTokensMap, favoriteTokens, isLoading, areTokensFromBridge, result, visibleTokens]) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.test.ts b/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.test.ts index 2ee3f2ac523..af047423796 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.test.ts @@ -113,6 +113,19 @@ describe('tokensToSelectAtom', () => { const result = await store.get(tokensToSelectAtom) expect(result).toEqual([token1, token2]) }) + + it('does not keep favorite tokens that are outside scoped sell lists', async () => { + store.set(mockEnvironmentAtom, { sellSelectedLists: [LIST_A] }) + store.set(mockFavoriteTokensListAtom, [token3]) + store.set(mockListsStatesMapAtom, { + [LIST_A]: makeListState(LIST_A, [token1.address, token2.address]), + [LIST_B]: makeListState(LIST_B, [token3.address]), + }) + + const result = await store.get(tokensToSelectAtom) + + expect(result).toEqual([token1, token2]) + }) }) describe('buy field (Field.OUTPUT)', () => { diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.ts index 9ccab5ec240..5a81cb0a38f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/state/tokensToSelectAtom.ts @@ -2,13 +2,7 @@ import { atom } from 'jotai' import { TokenWithLogo } from '@cowprotocol/common-const' import { getAddressKey, AddressKey } from '@cowprotocol/cow-sdk' -import { - allActiveTokensAtom, - environmentAtom, - favoriteTokensListAtom, - listsStatesMapAtom, - TokenListsState, -} from '@cowprotocol/tokens' +import { allActiveTokensAtom, environmentAtom, listsStatesMapAtom, TokenListsState } from '@cowprotocol/tokens' import { Field } from 'legacy/state/types' @@ -18,25 +12,12 @@ type TokensToSelectPerField = Record => { const allActive = await get(allActiveTokensAtom) - const favoriteTokens = get(favoriteTokensListAtom) const listsStatesMap = await get(listsStatesMapAtom) - const { sellSelectedLists, buySelectedLists, hideFavoriteTokens } = get(environmentAtom) + const { sellSelectedLists, buySelectedLists } = get(environmentAtom) return { - [Field.INPUT]: getTokensBySelectedLists( - allActive.tokens, - listsStatesMap, - sellSelectedLists, - favoriteTokens, - hideFavoriteTokens, - ), - [Field.OUTPUT]: getTokensBySelectedLists( - allActive.tokens, - listsStatesMap, - buySelectedLists, - favoriteTokens, - hideFavoriteTokens, - ), + [Field.INPUT]: getTokensBySelectedLists(allActive.tokens, listsStatesMap, sellSelectedLists), + [Field.OUTPUT]: getTokensBySelectedLists(allActive.tokens, listsStatesMap, buySelectedLists), } }) @@ -51,8 +32,6 @@ function getTokensBySelectedLists( allTokens: TokenWithLogo[], listsStatesMap: TokenListsState, selectedLists: string[] | undefined, - favoriteTokens: TokenWithLogo[], - hideFavoriteTokens: boolean | undefined, ): TokenWithLogo[] { /** * Widget-specific feature. @@ -72,13 +51,5 @@ function getTokensBySelectedLists( return acc }, new Set()) - const favoriteTokenAddresses = hideFavoriteTokens - ? null - : new Set(favoriteTokens.map((token) => getAddressKey(token.address))) - - return allTokens.filter((token) => { - const tokenAddress = getAddressKey(token.address) - - return availableTokens.has(tokenAddress) || !!favoriteTokenAddresses?.has(tokenAddress) - }) + return allTokens.filter((token) => availableTokens.has(getAddressKey(token.address))) } diff --git a/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts index 8fc353ebfc5..53d4325aeae 100644 --- a/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts +++ b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts @@ -2,8 +2,30 @@ import { createStore } from 'jotai' import { SupportedChainId } from '@cowprotocol/cow-sdk' +jest.mock('@cowprotocol/common-const', () => ({ + ...jest.requireActual('@cowprotocol/common-const'), + COW_CDN: 'https://cdn.cow.fi', +})) + +jest.mock('../environmentAtom', () => { + const { atom } = require('jotai') + const { SupportedChainId } = require('@cowprotocol/cow-sdk') + + const environmentAtom = atom({ + chainId: SupportedChainId.MAINNET, + }) + const updateEnvironmentAtom = atom(null, (get, set, update: Record) => { + set(environmentAtom, { ...get(environmentAtom), ...update }) + }) + + return { + environmentAtom, + updateEnvironmentAtom, + } +}) + import { removeListAtom, upsertListsAtom } from './tokenListsActionsAtom' -import { listsStatesByChainAtom, listsStatesMapAtom } from './tokenListsStateAtom' +import { listsStatesByChainAtom, listsStatesMapAtom, virtualListsStateAtom } from './tokenListsStateAtom' import { ListState, TokenListsByChainState } from '../../types' import { environmentAtom } from '../environmentAtom' @@ -62,6 +84,26 @@ const MOCK_LIST_STATE_2: ListState = { isEnabled: true, } +const MOCK_VIRTUAL_LIST_STATE: ListState = { + source: 'widgetCustomTokens', + widgetAppCode: 'widget-test', + list: { + name: 'Widget custom tokens', + timestamp: '2024-01-01T00:00:00Z', + version: { major: 1, minor: 0, patch: 0 }, + tokens: [ + { + chainId: 1, + address: '0x3234567890123456789012345678901234567890', + name: 'Widget Token', + symbol: 'WIDGET', + decimals: 18, + }, + ], + }, + isEnabled: true, +} + describe('listsStatesByChainAtom - token lists state', () => { describe('listsStatesMapAtom', () => { it('filters out deleted entries from the list state', async () => { @@ -135,6 +177,34 @@ describe('listsStatesByChainAtom - token lists state', () => { expect(Object.keys(listsStatesMap)).toHaveLength(0) }) + + it('keeps virtual widget lists when curated-only mode is enabled', async () => { + const store = createStore() + + const stateWithoutWidgetLists: TokenListsByChainState = { + ...DEFAULT_LISTS_STATE, + [MOCK_CHAIN_ID]: { + [MOCK_LIST_STATE.source]: MOCK_LIST_STATE, + }, + } + + store.set(environmentAtom, { + chainId: MOCK_CHAIN_ID, + widgetAppCode: 'widget-test', + selectedLists: ['widgetcustomtokens'], + useCuratedListOnly: true, + isYieldEnabled: false, + }) + store.set(listsStatesByChainAtom, stateWithoutWidgetLists) + store.set(virtualListsStateAtom, { + [MOCK_VIRTUAL_LIST_STATE.source]: MOCK_VIRTUAL_LIST_STATE, + }) + + const listsStatesMap = await store.get(listsStatesMapAtom) + + expect(listsStatesMap[MOCK_VIRTUAL_LIST_STATE.source]).toEqual(MOCK_VIRTUAL_LIST_STATE) + expect(listsStatesMap[MOCK_LIST_STATE.source]).toBeUndefined() + }) }) describe('removeListAtom', () => { diff --git a/libs/tokens/src/state/tokenLists/tokenListsStateAtom.ts b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.ts index f80e7875cb7..95887f06eed 100644 --- a/libs/tokens/src/state/tokenLists/tokenListsStateAtom.ts +++ b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.ts @@ -117,13 +117,20 @@ export const listsStatesMapAtom = atom(async (get) => { return acc }, {}) + const virtualListSources = Object.keys(virtualListsState).reduce<{ [key: string]: boolean }>((acc, source) => { + acc[source] = true + return acc + }, {}) + const lpTokenListSources = LP_TOKEN_LISTS.reduce<{ [key: string]: boolean }>((acc, list) => { acc[list.source] = true return acc }, {}) const listsSources = Object.keys(currentNetworkLists).filter((source) => { - return useCuratedListOnly ? userAddedListSources[source] || lpTokenListSources[source] : true + return useCuratedListOnly + ? userAddedListSources[source] || virtualListSources[source] || lpTokenListSources[source] + : true }) const lists = useCuratedListOnly From ba1fa39983e6caf033d9dcfc09dd7d782558faf8 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:17:23 +0100 Subject: [PATCH 2/4] fix: scope bridge favorites to widget token lists --- .../hooks/useTokensToSelect.test.ts | 24 +++++++++++++++++++ .../tokensList/hooks/useTokensToSelect.ts | 13 ++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts index 114f3ef555f..8612b68ad7b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts @@ -201,4 +201,28 @@ describe('useTokensToSelect', () => { expect(result.current.tokens).toEqual([scopedFavoriteToken]) expect(result.current.favoriteTokens).toEqual([scopedFavoriteToken]) }) + + it('filters bridge favorites to the field-scoped selectable token set', () => { + const scopedBridgeFavorite = { ...scopedFavoriteToken, chainId: SupportedChainId.LINEA } as TokenWithLogo + const leakedBridgeFavorite = { ...leakedFavoriteToken, chainId: SupportedChainId.LINEA } as TokenWithLogo + + mockUseAtomValue.mockReturnValue([scopedBridgeFavorite]) + mockUseFavoriteTokens.mockReturnValue([scopedBridgeFavorite, leakedBridgeFavorite]) + mockUseBridgeSupportedTokens.mockReturnValue({ + data: { tokens: [scopedBridgeFavorite, leakedBridgeFavorite], isRouteAvailable: true }, + isLoading: false, + } as ReturnType) + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.OUTPUT, + selectedTargetChainId: SupportedChainId.LINEA, + oppositeToken: mainnetToken, + }), + ) + + const { result } = renderHook(() => useTokensToSelect()) + + expect(result.current.tokens).toEqual([scopedBridgeFavorite, leakedBridgeFavorite]) + expect(result.current.favoriteTokens).toEqual([scopedBridgeFavorite]) + }) }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts index 738927d9029..37a18ada5bd 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts @@ -72,13 +72,18 @@ export function useTokensToSelect(): TokensToSelectContext { }, [allTokens, areTokensFromBridge, result]) return useMemo(() => { - // In bridge mode, hide favorites until we know what's actually bridgeable for this chain pair. - // This avoids selecting a favorite token and then getting it cleared by async validation. + // Favorites are shortcuts, not permissions: keep them inside the field-scoped token set. + // In bridge mode they must also be bridgeable for the current chain pair. + const scopedTokenAddresses = new Set(allTokens.map((token) => getAddressKey(token.address))) const visibleTokenAddresses = new Set(visibleTokens.map((token) => getAddressKey(token.address))) const favoriteTokensToSelect = areTokensFromBridge && bridgeSupportedTokensMap === null ? EMPTY_TOKENS - : favoriteTokens.filter((token) => visibleTokenAddresses.has(getAddressKey(token.address))) + : favoriteTokens.filter((token) => { + const address = getAddressKey(token.address) + + return visibleTokenAddresses.has(address) && (!areTokensFromBridge || scopedTokenAddresses.has(address)) + }) return { isLoading: areTokensFromBridge ? isLoading : false, @@ -88,5 +93,5 @@ export function useTokensToSelect(): TokensToSelectContext { isRouteAvailable: result?.isRouteAvailable, bridgeSupportedTokensMap, } - }, [bridgeSupportedTokensMap, favoriteTokens, isLoading, areTokensFromBridge, result, visibleTokens]) + }, [allTokens, bridgeSupportedTokensMap, favoriteTokens, isLoading, areTokensFromBridge, result, visibleTokens]) } From b5e97d55040849535240920d5fcc3b003b7024ba Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:53:36 +0100 Subject: [PATCH 3/4] fix: scope recent tokens to widget token lists --- .../hooks/useRecentTokenSection.ts | 2 + .../containers/TokenSearchResults/index.tsx | 22 +----- .../hooks/useHydratedRecentTokens.ts | 8 +- .../tokensList/hooks/useRecentTokens.test.tsx | 76 +++++++++++++++++++ .../tokensList/hooks/useRecentTokens.ts | 7 ++ .../hooks/useTokenListContext.test.ts | 23 +++++- .../tokensList/hooks/useTokenListContext.ts | 4 + .../hooks/useTokensToSelect.test.ts | 42 +++++++++- .../tokensList/hooks/useTokensToSelect.ts | 37 ++++++++- 9 files changed, 198 insertions(+), 23 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useRecentTokenSection.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useRecentTokenSection.ts index 121eece306e..b2c304de680 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useRecentTokenSection.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useRecentTokenSection.ts @@ -14,10 +14,12 @@ export function useRecentTokenSection( allTokens: TokenWithLogo[], favoriteTokens: TokenWithLogo[], activeChainId?: number, + allowedTokens?: TokenWithLogo[], ): RecentTokenSection { const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ allTokens, favoriteTokens, + allowedTokens, activeChainId, }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx index 044159a4564..e8069267b18 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx @@ -4,10 +4,6 @@ import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' import { getAddressKey } from '@cowprotocol/cow-sdk' import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens' -import { useInjectedWidgetParams } from 'entities/injectedWidget' - -import { Field } from 'legacy/state/types' - import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback' import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' import { useTokenListContext } from '../../hooks/useTokenListContext' @@ -19,28 +15,16 @@ import { TokenSearchContent } from '../../pure/TokenSearchContent' export function TokenSearchResults(): ReactNode { const { searchInput } = useTokenListViewState() - const { selectTokenContext, areTokensFromBridge, allTokens, bridgeSupportedTokensMap } = useTokenListContext() - const { tokenLists, sellTokenLists, buyTokenLists } = useInjectedWidgetParams() + const { selectTokenContext, areTokensFromBridge, allTokens, bridgeSupportedTokensMap, hasScopedListRestriction } = + useTokenListContext() const { onTokenListItemClick } = selectTokenContext - const { field, onSelectToken } = useSelectTokenWidgetState() + const { onSelectToken } = useSelectTokenWidgetState() // Search all tokens (used in both modes) const defaultSearchResults = useSearchToken(searchInput) const filter = useMemo(() => getTokenSearchFilter(searchInput), [searchInput]) - const hasScopedListRestriction = useMemo(() => { - if (field === Field.INPUT) { - return !!(tokenLists?.length || sellTokenLists?.length) - } - - if (field === Field.OUTPUT) { - return !!(tokenLists?.length || buyTokenLists?.length) - } - - return !!(tokenLists?.length || sellTokenLists?.length || buyTokenLists?.length) - }, [buyTokenLists?.length, field, sellTokenLists?.length, tokenLists?.length]) - const searchResults: TokenSearchResponse = useMemo(() => { if (!hasScopedListRestriction && !areTokensFromBridge) { return defaultSearchResults diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useHydratedRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useHydratedRecentTokens.ts index 1cbbe1cab72..bf6d9adfbc8 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useHydratedRecentTokens.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useHydratedRecentTokens.ts @@ -13,6 +13,7 @@ interface UseHydratedRecentTokensParams { storedTokensByChain: StoredRecentTokensByChain tokensByKey: Map favoriteKeys: Set + allowedTokenKeys?: Set activeChainId?: number maxItems?: number } @@ -21,6 +22,7 @@ export function useHydratedRecentTokens({ storedTokensByChain, tokensByKey, favoriteKeys, + allowedTokenKeys, activeChainId, maxItems = RECENT_TOKENS_LIMIT, }: UseHydratedRecentTokensParams): TokenWithLogo[] { @@ -36,6 +38,10 @@ export function useHydratedRecentTokens({ continue } + if (allowedTokenKeys && !allowedTokenKeys.has(key)) { + continue + } + const hydrated = hydrateStoredToken(entry, tokensByKey.get(key)) if (hydrated) { @@ -49,5 +55,5 @@ export function useHydratedRecentTokens({ } return result - }, [activeChainId, favoriteKeys, maxItems, storedTokensByChain, tokensByKey]) + }, [activeChainId, allowedTokenKeys, favoriteKeys, maxItems, storedTokensByChain, tokensByKey]) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.test.tsx b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.test.tsx index 5e9c4ca189b..81084625c01 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.test.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.test.tsx @@ -93,6 +93,82 @@ describe('useRecentTokens', () => { expect(result.current.recentTokens[0].symbol).toBe('TKN') }) + it('hydrates persisted custom tokens when no token-list restriction is active', () => { + setStoredTokens({ + [SupportedChainId.MAINNET]: [createStoredToken(SupportedChainId.MAINNET, ADDRESS_1, 'CUSTOM')], + }) + const store = createStoreWithLocalStorage() + const wrapper = createTestWrapper(store) + + const { result } = renderHook( + () => + useRecentTokens({ + allTokens: [], + favoriteTokens: [], + activeChainId: SupportedChainId.MAINNET, + }), + { wrapper }, + ) + + expect(result.current.recentTokens).toHaveLength(1) + expect(result.current.recentTokens[0].symbol).toBe('CUSTOM') + }) + + it('omits persisted tokens outside the allowed token set', () => { + const allowedToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'ALLOWED') + const blockedToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_2, 'BLOCKED') + + setStoredTokens({ + [SupportedChainId.MAINNET]: [ + createStoredToken(SupportedChainId.MAINNET, ADDRESS_2, 'BLOCKED'), + createStoredToken(SupportedChainId.MAINNET, ADDRESS_1, 'ALLOWED'), + ], + }) + const store = createStoreWithLocalStorage() + const wrapper = createTestWrapper(store) + + const { result } = renderHook( + () => + useRecentTokens({ + allTokens: [allowedToken, blockedToken], + favoriteTokens: [], + allowedTokens: [allowedToken], + activeChainId: SupportedChainId.MAINNET, + }), + { wrapper }, + ) + + expect(result.current.recentTokens).toEqual([allowedToken]) + }) + + it('filters blocked tokens before applying the recent-token limit', () => { + const allowedToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'ALLOWED') + const blockedToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_2, 'BLOCKED') + + setStoredTokens({ + [SupportedChainId.MAINNET]: [ + createStoredToken(SupportedChainId.MAINNET, ADDRESS_2, 'BLOCKED'), + createStoredToken(SupportedChainId.MAINNET, ADDRESS_1, 'ALLOWED'), + ], + }) + const store = createStoreWithLocalStorage() + const wrapper = createTestWrapper(store) + + const { result } = renderHook( + () => + useRecentTokens({ + allTokens: [allowedToken, blockedToken], + favoriteTokens: [], + allowedTokens: [allowedToken], + activeChainId: SupportedChainId.MAINNET, + maxItems: 1, + }), + { wrapper }, + ) + + expect(result.current.recentTokens).toEqual([allowedToken]) + }) + it('does not return tokens from other chains', () => { setStoredTokens({ [SupportedChainId.GNOSIS_CHAIN]: [createStoredToken(SupportedChainId.GNOSIS_CHAIN, ADDRESS_1, 'TKN')], diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts index c9d41c34e03..2cd8439de3b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts @@ -15,6 +15,7 @@ import { interface UseRecentTokensParams { allTokens: TokenWithLogo[] favoriteTokens: TokenWithLogo[] + allowedTokens?: TokenWithLogo[] activeChainId?: number maxItems?: number } @@ -28,11 +29,16 @@ export interface RecentTokensState { export function useRecentTokens({ allTokens, favoriteTokens, + allowedTokens, activeChainId, maxItems = RECENT_TOKENS_LIMIT, }: UseRecentTokensParams): RecentTokensState { const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens]) const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens]) + const allowedTokenKeys = useMemo( + () => (allowedTokens ? buildFavoriteTokenKeys(allowedTokens) : undefined), + [allowedTokens], + ) const { storedTokensByChain, @@ -47,6 +53,7 @@ export function useRecentTokens({ storedTokensByChain, tokensByKey, favoriteKeys, + allowedTokenKeys, activeChainId, maxItems, }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.test.ts index 0cf61889a80..c7ba24ced9b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.test.ts @@ -98,6 +98,8 @@ describe('useTokenListContext', () => { isLoading: false, tokens: [mockToken], favoriteTokens: [mockToken], + allowedRecentTokens: [mockToken], + hasScopedListRestriction: true, areTokensFromBridge: true, isRouteAvailable: true, bridgeSupportedTokensMap: { [mockToken.address.toLowerCase()]: true }, @@ -120,7 +122,26 @@ describe('useTokenListContext', () => { it('uses resolved default chain for active chain and exposed target chain', () => { const { result } = renderHook(() => useTokenListContext()) - expect(mockUseRecentTokenSection).toHaveBeenCalledWith([mockToken], [mockToken], SupportedChainId.LINEA) + expect(mockUseRecentTokenSection).toHaveBeenCalledWith([mockToken], [mockToken], SupportedChainId.LINEA, [ + mockToken, + ]) expect(result.current.selectedTargetChainId).toBe(SupportedChainId.LINEA) }) + + it('preserves unrestricted recent-token hydration when no allowed set is provided', () => { + mockUseTokensToSelect.mockReturnValue({ + isLoading: false, + tokens: [mockToken], + favoriteTokens: [], + allowedRecentTokens: undefined, + hasScopedListRestriction: false, + areTokensFromBridge: false, + isRouteAvailable: undefined, + bridgeSupportedTokensMap: null, + }) + + renderHook(() => useTokenListContext()) + + expect(mockUseRecentTokenSection).toHaveBeenCalledWith([mockToken], [], SupportedChainId.LINEA, undefined) + }) }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts index e8fbf262ee1..d8632ea3d2d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts @@ -27,6 +27,7 @@ export interface TokenListContext { // Loading state areTokensLoading: boolean areTokensFromBridge: boolean + hasScopedListRestriction: boolean // Bridge support map (null when loading, populated when bridge tokens are fetched) bridgeSupportedTokensMap: Record | null @@ -59,6 +60,7 @@ export function useTokenListContext(): TokenListContext { tokensState.tokens, tokensState.favoriteTokens, activeChainId, + tokensState.allowedRecentTokens, ) // Favorite tokens (empty in standalone mode) @@ -77,6 +79,7 @@ export function useTokenListContext(): TokenListContext { recentTokens, areTokensLoading: tokensState.isLoading, areTokensFromBridge: tokensState.areTokensFromBridge, + hasScopedListRestriction: tokensState.hasScopedListRestriction, bridgeSupportedTokensMap: tokensState.bridgeSupportedTokensMap, hideFavoriteTokensTooltip: isInjectedWidget(), selectedTargetChainId, @@ -88,6 +91,7 @@ export function useTokenListContext(): TokenListContext { tokensState.tokens, tokensState.isLoading, tokensState.areTokensFromBridge, + tokensState.hasScopedListRestriction, tokensState.bridgeSupportedTokensMap, favoriteTokens, recentTokens, diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts index 8612b68ad7b..63d66e3e50a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.test.ts @@ -7,6 +7,7 @@ import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet' import { renderHook } from '@testing-library/react' import { useBridgeSupportedTokens } from 'entities/bridgeProvider' +import { useInjectedWidgetParams } from 'entities/injectedWidget' import { Field } from 'legacy/state/types' @@ -31,6 +32,10 @@ jest.mock('entities/bridgeProvider', () => ({ useBridgeSupportedTokens: jest.fn(), })) +jest.mock('entities/injectedWidget', () => ({ + useInjectedWidgetParams: jest.fn(), +})) + jest.mock('./useSelectTokenWidgetState', () => ({ useSelectTokenWidgetState: jest.fn(), })) @@ -47,6 +52,7 @@ const mockUseAtomValue = useAtomValue as jest.MockedFunction const mockUseFavoriteTokens = useFavoriteTokens as jest.MockedFunction const mockUseBridgeSupportedTokens = useBridgeSupportedTokens as jest.MockedFunction +const mockUseInjectedWidgetParams = useInjectedWidgetParams as jest.MockedFunction const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction const mockUseChainsToSelect = useChainsToSelect as jest.MockedFunction @@ -113,6 +119,7 @@ describe('useTokensToSelect', () => { mockUseAtomValue.mockReturnValue([mainnetToken]) mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo) mockUseFavoriteTokens.mockReturnValue([]) + mockUseInjectedWidgetParams.mockReturnValue({}) mockUseBridgeSupportedTokens.mockReturnValue({ data: { tokens: [lineaToken], isRouteAvailable: true }, isLoading: false, @@ -141,6 +148,7 @@ describe('useTokensToSelect', () => { }) expect(result.current.areTokensFromBridge).toBe(true) expect(result.current.tokens).toEqual([lineaToken]) + expect(result.current.allowedRecentTokens).toBeUndefined() }) it('passes sellChainId/buyChainId when selecting output token on a different chain', () => { @@ -202,12 +210,43 @@ describe('useTokensToSelect', () => { expect(result.current.favoriteTokens).toEqual([scopedFavoriteToken]) }) + it.each([ + ['sellTokenLists', Field.INPUT, { sellTokenLists: ['https://example.com/sell.json'] }, true], + ['buyTokenLists', Field.OUTPUT, { buyTokenLists: ['https://example.com/buy.json'] }, true], + ['shared tokenLists input', Field.INPUT, { tokenLists: ['https://example.com/shared.json'] }, true], + ['shared tokenLists output', Field.OUTPUT, { tokenLists: ['https://example.com/shared.json'] }, true], + ['buyTokenLists on input', Field.INPUT, { buyTokenLists: ['https://example.com/buy.json'] }, false], + ['sellTokenLists on output', Field.OUTPUT, { sellTokenLists: ['https://example.com/sell.json'] }, false], + ])('applies the expected recent-token scope for %s', (_label, field, widgetParams, isRestricted) => { + mockUseAtomValue.mockReturnValue([scopedFavoriteToken]) + mockUseInjectedWidgetParams.mockReturnValue(widgetParams) + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useTokensToSelect()) + + expect(result.current.hasScopedListRestriction).toBe(isRestricted) + expect(result.current.allowedRecentTokens).toEqual(isRestricted ? [scopedFavoriteToken] : undefined) + }) + it('filters bridge favorites to the field-scoped selectable token set', () => { const scopedBridgeFavorite = { ...scopedFavoriteToken, chainId: SupportedChainId.LINEA } as TokenWithLogo + const scopedUnsupportedRecent = { + address: '0x3333333333333333333333333333333333333333', + chainId: SupportedChainId.LINEA, + decimals: 18, + symbol: 'RECENT', + name: 'Scoped unsupported recent', + } as TokenWithLogo const leakedBridgeFavorite = { ...leakedFavoriteToken, chainId: SupportedChainId.LINEA } as TokenWithLogo - mockUseAtomValue.mockReturnValue([scopedBridgeFavorite]) + mockUseAtomValue.mockReturnValue([scopedBridgeFavorite, scopedUnsupportedRecent]) mockUseFavoriteTokens.mockReturnValue([scopedBridgeFavorite, leakedBridgeFavorite]) + mockUseInjectedWidgetParams.mockReturnValue({ buyTokenLists: ['https://example.com/buy.json'] }) mockUseBridgeSupportedTokens.mockReturnValue({ data: { tokens: [scopedBridgeFavorite, leakedBridgeFavorite], isRouteAvailable: true }, isLoading: false, @@ -224,5 +263,6 @@ describe('useTokensToSelect', () => { expect(result.current.tokens).toEqual([scopedBridgeFavorite, leakedBridgeFavorite]) expect(result.current.favoriteTokens).toEqual([scopedBridgeFavorite]) + expect(result.current.allowedRecentTokens).toEqual([scopedBridgeFavorite, scopedUnsupportedRecent]) }) }) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts index 37a18ada5bd..83f1da18e32 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts @@ -8,6 +8,7 @@ import { useFavoriteTokens } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' import { useBridgeSupportedTokens } from 'entities/bridgeProvider' +import { useInjectedWidgetParams } from 'entities/injectedWidget' import { Field } from 'legacy/state/types' @@ -22,6 +23,8 @@ export interface TokensToSelectContext { isLoading: boolean tokens: TokenWithLogo[] favoriteTokens: TokenWithLogo[] + allowedRecentTokens: TokenWithLogo[] | undefined + hasScopedListRestriction: boolean areTokensFromBridge: boolean isRouteAvailable: boolean | undefined bridgeSupportedTokensMap: Record | null @@ -31,6 +34,7 @@ export function useTokensToSelect(): TokensToSelectContext { const { chainId } = useWalletInfo() const favoriteTokens = useFavoriteTokens() const { selectedTargetChainId = chainId, field, oppositeToken } = useSelectTokenWidgetState() + const { tokenLists, sellTokenLists, buyTokenLists } = useInjectedWidgetParams() const chainsToSelect = useChainsToSelect() const allTokens = useAtomValue(tokensToSelectAtom) const targetChainId = chainsToSelect?.defaultChainId ?? selectedTargetChainId @@ -47,6 +51,7 @@ export function useTokensToSelect(): TokensToSelectContext { }, [chainId, field, oppositeToken]) const areTokensFromBridge = field === Field.OUTPUT && targetChainId !== sourceChainId + const hasScopedListRestriction = getHasScopedListRestriction(field, tokenLists, sellTokenLists, buyTokenLists) const params: BuyTokensParams | undefined = useMemo(() => { if (!areTokensFromBridge) return undefined @@ -71,6 +76,8 @@ export function useTokensToSelect(): TokensToSelectContext { return (areTokensFromBridge ? result?.tokens : allTokens) || EMPTY_TOKENS }, [allTokens, areTokensFromBridge, result]) + const allowedRecentTokens = hasScopedListRestriction ? allTokens : undefined + return useMemo(() => { // Favorites are shortcuts, not permissions: keep them inside the field-scoped token set. // In bridge mode they must also be bridgeable for the current chain pair. @@ -89,9 +96,37 @@ export function useTokensToSelect(): TokensToSelectContext { isLoading: areTokensFromBridge ? isLoading : false, tokens: visibleTokens, favoriteTokens: favoriteTokensToSelect, + allowedRecentTokens, + hasScopedListRestriction, areTokensFromBridge, isRouteAvailable: result?.isRouteAvailable, bridgeSupportedTokensMap, } - }, [allTokens, bridgeSupportedTokensMap, favoriteTokens, isLoading, areTokensFromBridge, result, visibleTokens]) + }, [ + allTokens, + allowedRecentTokens, + bridgeSupportedTokensMap, + favoriteTokens, + hasScopedListRestriction, + isLoading, + areTokensFromBridge, + result, + visibleTokens, + ]) +} + +function getHasScopedListRestriction( + field: Field | undefined, + tokenLists: string[] | undefined, + sellTokenLists: string[] | undefined, + buyTokenLists: string[] | undefined, +): boolean { + const applicableLists = + field === Field.INPUT + ? [tokenLists, sellTokenLists] + : field === Field.OUTPUT + ? [tokenLists, buyTokenLists] + : [tokenLists, sellTokenLists, buyTokenLists] + + return applicableLists.some((lists) => Boolean(lists?.length)) } From 07fdbf72f3075bc8749d9ae3d5a231c2742373ae Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:03:11 +0100 Subject: [PATCH 4/4] refactor: generalize token key set helper --- .../src/modules/tokensList/hooks/useRecentTokens.ts | 9 +++------ .../src/modules/tokensList/utils/recentTokensStorage.ts | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts index 2cd8439de3b..2a487af6217 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts @@ -6,7 +6,7 @@ import { useHydratedRecentTokens } from './useHydratedRecentTokens' import { useRecentTokensStorage } from './useRecentTokensStorage' import { - buildFavoriteTokenKeys, + buildTokenKeySet, buildTokensByKey, persistRecentTokenSelection as persistRecentTokenSelectionInternal, RECENT_TOKENS_LIMIT, @@ -34,11 +34,8 @@ export function useRecentTokens({ maxItems = RECENT_TOKENS_LIMIT, }: UseRecentTokensParams): RecentTokensState { const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens]) - const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens]) - const allowedTokenKeys = useMemo( - () => (allowedTokens ? buildFavoriteTokenKeys(allowedTokens) : undefined), - [allowedTokens], - ) + const favoriteKeys = useMemo(() => buildTokenKeySet(favoriteTokens), [favoriteTokens]) + const allowedTokenKeys = useMemo(() => (allowedTokens ? buildTokenKeySet(allowedTokens) : undefined), [allowedTokens]) const { storedTokensByChain, diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/recentTokensStorage.ts index ede490af47b..f67696aa45f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/utils/recentTokensStorage.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/recentTokensStorage.ts @@ -18,7 +18,7 @@ export interface StoredRecentToken { export type StoredRecentTokensByChain = Record -export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set { +export function buildTokenKeySet(tokens: TokenWithLogo[]): Set { const set = new Set() for (const token of tokens) { @@ -83,7 +83,7 @@ export function persistRecentTokenSelection( favoriteTokens: TokenWithLogo[], maxItems = RECENT_TOKENS_LIMIT, ): void { - const favoriteKeys = buildFavoriteTokenKeys(favoriteTokens) + const favoriteKeys = buildTokenKeySet(favoriteTokens) if (favoriteKeys.has(getTokenId(token))) { return