diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index e6a627db37c..28898352bbd 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -902,6 +902,12 @@ msgstr "Unsupported Token" #~ msgid "Limit price (incl. costs)" #~ msgstr "Limit price (incl. costs)" +#: apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx +#: apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts +#: apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts +msgid "Checking token availability." +msgstr "Checking token availability." + #: apps/cowswap-frontend/src/modules/ordersTable/pure/ContextMenu/OrderContextMenu.pure.tsx msgid "Order receipt" msgstr "Order receipt" @@ -1984,6 +1990,10 @@ msgstr "Import List" msgid "TWAP orders currently require a Safe with a special fallback handler. Have one? Switch to it! Need setup? <0>Click here. Future updates may extend wallet support!" msgstr "TWAP orders currently require a Safe with a special fallback handler. Have one? Switch to it! Need setup? <0>Click here. Future updates may extend wallet support!" +#: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx +msgid "Checking token availability" +msgstr "Checking token availability" + #: apps/cowswap-frontend/src/modules/orderProgressBar/constants.ts msgid "CoW Swap was the first DEX to offer intent-based trading, gasless swaps, coincidences of wants, and many other DeFi innovations." msgstr "CoW Swap was the first DEX to offer intent-based trading, gasless swaps, coincidences of wants, and many other DeFi innovations." diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx index 762c15676b4..00d1d59f106 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx @@ -118,7 +118,7 @@ export function Updaters(): ReactNode { isYieldEnabled={isYieldEnabled} bridgeNetworkInfo={bridgeNetworkInfo?.data} /> - + diff --git a/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts b/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts index 7a7903060c0..c5f8c33f62d 100644 --- a/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts +++ b/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts @@ -1,7 +1,7 @@ import { useSetAtom } from 'jotai' import { useMemo } from 'react' -import { WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/common-const' +import { TokenWithLogo, WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/common-const' import { Command } from '@cowprotocol/types' import { useWalletInfo } from '@cowprotocol/wallet' @@ -10,8 +10,9 @@ import { Field } from 'legacy/state/types' import { MAX_APPROVE_AMOUNT, TradeApproveCallback } from 'modules/erc20Approve' import { useIsInfiniteApproveDisabledInWidget } from 'modules/injectedWidget' +import { RwaTokenStatus, useRwaConsentModalState, useRwaTokenStatus } from 'modules/rwa' import { useSwapPartialApprovalToggleState } from 'modules/swap/hooks/useSwapSettings' -import { useOnCurrencySelection, useTradeConfirmActions } from 'modules/trade' +import { useDerivedTradeState, useOnCurrencySelection, useTradeConfirmActions } from 'modules/trade' import { updateEthFlowContextAtom } from '../../../state/ethFlowContextAtom' @@ -32,14 +33,21 @@ export interface EthFlowActions { directSwap(): void } +// eslint-disable-next-line max-lines-per-function export function useEthFlowActions(callbacks: EthFlowActionCallbacks, amountToApprove?: bigint): EthFlowActions { const { chainId } = useWalletInfo() + const { outputCurrency } = useDerivedTradeState() || {} const updateEthFlowContext = useSetAtom(updateEthFlowContextAtom) const onCurrencySelection = useOnCurrencySelection() const { onOpen: openSwapConfirmModal } = useTradeConfirmActions() + const { openModal: openRwaConsentModal } = useRwaConsentModalState() const [isPartialApproveEnabledBySettings] = useSwapPartialApprovalToggleState() const isInfiniteApproveDisabledInWidget = useIsInfiniteApproveDisabledInWidget() + const { status: rwaStatus, rwaTokenInfo } = useRwaTokenStatus({ + inputCurrency: WRAPPED_NATIVE_CURRENCIES[chainId], + outputCurrency, + }) return useMemo(() => { function sendTransaction(type: 'approve' | 'wrap', callback: () => Promise): Promise { @@ -61,6 +69,19 @@ export function useEthFlowActions(callbacks: EthFlowActionCallbacks, amountToApp } const swap = async (): Promise => { + if (rwaStatus === RwaTokenStatus.ChecksPending || rwaStatus === RwaTokenStatus.Restricted) { + return + } + + if (rwaStatus === RwaTokenStatus.RequiredConsent && rwaTokenInfo) { + callbacks.dismiss() + openRwaConsentModal({ + consentHash: rwaTokenInfo.consentHash, + token: TokenWithLogo.fromToken(rwaTokenInfo.token), + }) + return + } + callbacks.dismiss() onCurrencySelection(Field.INPUT, WRAPPED_NATIVE_CURRENCIES[chainId], () => { openSwapConfirmModal(true) @@ -114,8 +135,11 @@ export function useEthFlowActions(callbacks: EthFlowActionCallbacks, amountToApp onCurrencySelection, chainId, openSwapConfirmModal, + openRwaConsentModal, isPartialApproveEnabledBySettings, isInfiniteApproveDisabledInWidget, amountToApprove, + rwaStatus, + rwaTokenInfo, ]) } diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx index 6e45d94f8a1..17a8f6be2e3 100644 --- a/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx +++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx @@ -12,13 +12,13 @@ import { useImportTokenWithConsent } from './useImportTokenWithConsent' import { RwaConsentModal } from '../pure/RwaConsentModal' -function getRestrictedFlowResult(): CustomFlowResult { +function getBlockedFlowResult(message: string): CustomFlowResult { return { content: null, data: { restriction: { isBlocked: true, - message: t`This token is not available in your region.`, + message, }, }, } @@ -38,7 +38,11 @@ export function useImportTokenConsentFlow(): ViewFlowConfig> = { [RwaTokenStatus.Allowed]: 'allowed', + [RwaTokenStatus.ChecksPending]: 'checks-pending', [RwaTokenStatus.Restricted]: 'restricted', [RwaTokenStatus.RequiredConsent]: 'requires-consent', [RwaTokenStatus.ConsentIsSigned]: 'allowed', diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts index 54b1a4cee51..a036bdf0989 100644 --- a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts +++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts @@ -1,9 +1,10 @@ +import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useFeatureFlags } from '@cowprotocol/common-hooks' import { areTokensEqual } from '@cowprotocol/cow-sdk' import { Currency, Token } from '@cowprotocol/currency' -import { getCountryAsKey, RestrictedTokenInfo, useAnyRestrictedToken } from '@cowprotocol/tokens' +import { getCountryAsKey, restrictedTokensAtom, RestrictedTokenInfo, useRestrictedToken } from '@cowprotocol/tokens' import { Nullish } from '@cowprotocol/types' import { useWalletInfo } from '@cowprotocol/wallet' @@ -31,6 +32,8 @@ export interface UseRwaTokenStatusParams { export enum RwaTokenStatus { /** No RWA restrictions - proceed normally */ Allowed = 'Allowed', + /** Restriction metadata is still loading - do not proceed yet */ + ChecksPending = 'ChecksPending', /** User's country is in the blocked list - cannot trade */ Restricted = 'Restricted', /** Country unknown/loading and consent not yet given - show consent modal */ @@ -43,21 +46,45 @@ export function useRwaTokenStatus({ inputCurrency, outputCurrency }: UseRwaToken const { isRwaGeoblockEnabled } = useFeatureFlags() const { account } = useWalletInfo() const geoStatus = useGeoStatus() + const restrictedTokensState = useAtomValue(restrictedTokensAtom) const inputToken = inputCurrency?.isToken ? inputCurrency : undefined const outputToken = outputCurrency?.isToken ? outputCurrency : undefined + const inputRestrictedToken = useRestrictedToken(inputToken) + const outputRestrictedToken = useRestrictedToken(outputToken) + const hasTokenToCheck = Boolean(inputToken || outputToken) - const restrictedTokenInfo = useAnyRestrictedToken(inputToken, outputToken) + const rwaTokenInfos = useMemo((): RwaTokenInfo[] => { + const tokensToCheck = [[inputToken, inputRestrictedToken] as const, [outputToken, outputRestrictedToken] as const] + + return tokensToCheck.reduce((acc, [token, restrictedToken]) => { + if (!token || !restrictedToken) { + return acc + } + + if (acc.some((item) => areTokensEqual(item.token, token))) { + return acc + } + + acc.push(convertToRwaTokenInfo(restrictedToken, token)) + + return acc + }, []) + }, [inputRestrictedToken, inputToken, outputRestrictedToken, outputToken]) const rwaTokenInfo = useMemo((): RwaTokenInfo | null => { - if (!restrictedTokenInfo) return null + if (rwaTokenInfos.length === 0) { + return null + } - const matchedToken = areTokensEqual(inputToken, restrictedTokenInfo.token) ? inputToken : outputToken + if (geoStatus.country === null) { + return rwaTokenInfos[0] + } - if (!matchedToken) return null + const countryKey = getCountryAsKey(geoStatus.country) - return convertToRwaTokenInfo(restrictedTokenInfo, matchedToken) - }, [restrictedTokenInfo, inputToken, outputToken]) + return rwaTokenInfos.find((token) => token.blockedCountries.has(countryKey)) ?? rwaTokenInfos[0] + }, [geoStatus.country, rwaTokenInfos]) const consentKey = useMemo((): RwaConsentKey | null => { if (!rwaTokenInfo || !account) { @@ -72,33 +99,49 @@ export function useRwaTokenStatus({ inputCurrency, outputCurrency }: UseRwaToken const { consentStatus } = useRwaConsentStatus(consentKey) const status = useMemo((): RwaTokenStatus => { - // If RWA geoblock feature is disabled, always allow trading + if (isRwaGeoblockEnabled === undefined) { + return hasTokenToCheck ? RwaTokenStatus.ChecksPending : RwaTokenStatus.Allowed + } + if (!isRwaGeoblockEnabled) { return RwaTokenStatus.Allowed } - if (!rwaTokenInfo) { + if (!hasTokenToCheck) { + return RwaTokenStatus.Allowed + } + + if (!restrictedTokensState.isLoaded) { + return RwaTokenStatus.ChecksPending + } + + if (rwaTokenInfos.length === 0) { return RwaTokenStatus.Allowed } - // Geo API response is PRIMARY - overrides any previous consent - // If we can determine the country, use it regardless of consent status - // Note: while loading, country is null so we fall through to consent check if (geoStatus.country !== null) { const countryKey = getCountryAsKey(geoStatus.country) - if (rwaTokenInfo.blockedCountries.has(countryKey)) { + + if (rwaTokenInfos.some((token) => token.blockedCountries.has(countryKey))) { return RwaTokenStatus.Restricted } + return RwaTokenStatus.Allowed } - // Country unknown (loading, failed, or unavailable) - fall back to consent check if (consentStatus === 'valid') { return RwaTokenStatus.ConsentIsSigned } return RwaTokenStatus.RequiredConsent - }, [isRwaGeoblockEnabled, rwaTokenInfo, geoStatus.country, consentStatus]) + }, [ + consentStatus, + geoStatus.country, + hasTokenToCheck, + isRwaGeoblockEnabled, + restrictedTokensState.isLoaded, + rwaTokenInfos, + ]) return useMemo(() => ({ status, rwaTokenInfo }), [status, rwaTokenInfo]) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts index cab77f49f53..a8c72f302b4 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts @@ -1,6 +1,7 @@ import { useAtomValue } from 'jotai' import { useMemo } from 'react' +import { useFeatureFlags } from '@cowprotocol/common-hooks' import { getSourceAsKey, ListState, restrictedListsAtom, useFilterBlockedLists } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' @@ -13,6 +14,7 @@ import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } */ export function useFilterListsWithConsent(lists: ListState[]): ListState[] { const { account } = useWalletInfo() + const { isRwaGeoblockEnabled } = useFeatureFlags() const geoStatus = useGeoStatus() const restrictedLists = useAtomValue(restrictedListsAtom) const consentCache = useAtomValue(rwaConsentCacheAtom) @@ -21,18 +23,16 @@ export function useFilterListsWithConsent(lists: ListState[]): ListState[] { const countryFilteredLists = useFilterBlockedLists(lists, geoStatus.country) return useMemo(() => { - // If country is known, just return country-filtered lists - if (geoStatus.country) { - return countryFilteredLists + if (isRwaGeoblockEnabled === false) { + return lists } - // If geo is still loading, return all lists for now - if (geoStatus.isLoading) { - return countryFilteredLists + if (isRwaGeoblockEnabled !== true || !restrictedLists.isLoaded) { + return [] } - // if restricted lists not loaded, return all - if (!restrictedLists.isLoaded) { + // If country is known, just return country-filtered lists + if (geoStatus.country) { return countryFilteredLists } @@ -55,5 +55,5 @@ export function useFilterListsWithConsent(lists: ListState[]): ListState[] { // Only show if consent is given return !!existingConsent?.acceptedAt }) - }, [countryFilteredLists, geoStatus, restrictedLists, account, consentCache]) + }, [account, consentCache, countryFilteredLists, geoStatus.country, isRwaGeoblockEnabled, lists, restrictedLists]) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts index d3ba6213855..b01d3c1b05a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts @@ -22,8 +22,7 @@ export function useIsListRequiresConsent(listSource: string | undefined): ListCo const consentCache = useAtomValue(rwaConsentCacheAtom) return useMemo(() => { - // skip consent check if ff is disabled - if (!isRwaGeoblockEnabled) { + if (isRwaGeoblockEnabled === false) { return { requiresConsent: false, consentHash: null, isLoading: false } } @@ -32,8 +31,7 @@ export function useIsListRequiresConsent(listSource: string | undefined): ListCo return { requiresConsent: false, consentHash: null, isLoading: false } } - // If still loading, return loading state - if (!restrictedLists.isLoaded || geoStatus.isLoading) { + if (isRwaGeoblockEnabled !== true || !restrictedLists.isLoaded) { return { requiresConsent: false, consentHash: null, isLoading: true } } @@ -51,9 +49,8 @@ export function useIsListRequiresConsent(listSource: string | undefined): ListCo } // Country is unknown - check if consent is given - // If no wallet connected, don't block - the consent modal will handle wallet connection if (!account) { - return { requiresConsent: false, consentHash, isLoading: false } + return { requiresConsent: true, consentHash, isLoading: false } } const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } @@ -65,5 +62,5 @@ export function useIsListRequiresConsent(listSource: string | undefined): ListCo consentHash, isLoading: false, } - }, [isRwaGeoblockEnabled, listSource, restrictedLists, geoStatus, account, consentCache]) + }, [account, consentCache, geoStatus.country, isRwaGeoblockEnabled, listSource, restrictedLists]) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts index 76de2358592..31bceb20c37 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts @@ -1,8 +1,9 @@ +import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' -import { getCountryAsKey, RestrictedTokenInfo, useRestrictedToken } from '@cowprotocol/tokens' +import { getCountryAsKey, restrictedTokensAtom, RestrictedTokenInfo, useRestrictedToken } from '@cowprotocol/tokens' import { t } from '@lingui/core/macro' @@ -27,23 +28,38 @@ const NOT_RESTRICTED_RESULT: RestrictedTokenImportResult = { blockReason: null, } +function getPendingRestrictionResult(): RestrictedTokenImportResult { + return { + status: RestrictedTokenImportStatus.Blocked, + restrictedInfo: null, + isImportDisabled: true, + blockReason: t`Checking token availability.`, + } +} + export function useRestrictedTokenImportStatus(token: TokenWithLogo | undefined): RestrictedTokenImportResult { const { isRwaGeoblockEnabled } = useFeatureFlags() const geoStatus = useGeoStatus() const restrictedInfo = useRestrictedToken(token) + const restrictedTokensState = useAtomValue(restrictedTokensAtom) return useMemo(() => { - // skip restriction check if ff is disabled + if (isRwaGeoblockEnabled === undefined) { + return getPendingRestrictionResult() + } + if (!isRwaGeoblockEnabled) { return NOT_RESTRICTED_RESULT } - // if geo is loading or token is not restricted, allow import - if (geoStatus.isLoading || !restrictedInfo) { + if (!restrictedTokensState.isLoaded) { + return getPendingRestrictionResult() + } + + if (!restrictedInfo) { return NOT_RESTRICTED_RESULT } - // only block import if country is known and blocked if (geoStatus.country) { const countryKey = getCountryAsKey(geoStatus.country) const blockedCountries = new Set(restrictedInfo.restrictedCountries) @@ -58,6 +74,6 @@ export function useRestrictedTokenImportStatus(token: TokenWithLogo | undefined) } } - return NOT_RESTRICTED_RESULT - }, [isRwaGeoblockEnabled, geoStatus, restrictedInfo]) + return getPendingRestrictionResult() + }, [geoStatus.country, isRwaGeoblockEnabled, restrictedInfo, restrictedTokensState.isLoaded]) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts index e4a84ddc0ec..ae24f58c269 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts @@ -61,6 +61,15 @@ export function useRestrictedTokensImportStatus(tokens: TokenWithLogo[]): Restri tokenNeedingConsent: rwaTokenInfo?.token ? TokenWithLogo.fromToken(rwaTokenInfo.token) : null, } + case RwaTokenStatus.ChecksPending: + return { + isImportDisabled: true, + blockReason: t`Checking token availability.`, + restrictedTokenInfo: rwaTokenInfo, + requiresConsent: false, + tokenNeedingConsent: null, + } + case RwaTokenStatus.Allowed: case RwaTokenStatus.ConsentIsSigned: default: 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/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx index 6226306972f..4ef2e3ee6f1 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx @@ -3,47 +3,67 @@ import { useEffect } from 'react' import { useFeatureFlags } from '@cowprotocol/common-hooks' import { blockedListSourcesAtom, getCountryAsKey, restrictedListsAtom } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' -import { useGeoStatus } from 'modules/rwa' +import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' /** - * update the blockedListSourcesAtom based on geo-blocking only: - * - only blocks lists when country is known and the list is blocked for that country - * - does not block when country is unknown (consent check happens at trade/import time) + * Keeps restricted token lists hidden until geoblocking checks are satisfied. */ export function BlockedListSourcesUpdater(): null { const { isRwaGeoblockEnabled } = useFeatureFlags() + const { account } = useWalletInfo() const geoStatus = useGeoStatus() const restrictedLists = useAtomValue(restrictedListsAtom) + const consentCache = useAtomValue(rwaConsentCacheAtom) const setBlockedListSources = useSetAtom(blockedListSourcesAtom) useEffect(() => { - // Skip blocking if feature flag is disabled - if (!isRwaGeoblockEnabled) { + if (isRwaGeoblockEnabled === false) { setBlockedListSources(new Set()) return } - if (!restrictedLists.isLoaded) { + if (isRwaGeoblockEnabled !== true || !restrictedLists.isLoaded) { return } const blockedSources = new Set() - // only block when country is known and list is blocked for that country - // when country is unknown, tokens should be visible (consent check happens at trade time) - if (geoStatus.country) { - const countryKey = getCountryAsKey(geoStatus.country) + for (const [sourceKey, blockedCountries] of Object.entries(restrictedLists.blockedCountriesPerList)) { + if (geoStatus.country) { + const countryKey = getCountryAsKey(geoStatus.country) - for (const [sourceKey, blockedCountries] of Object.entries(restrictedLists.blockedCountriesPerList)) { if (blockedCountries.includes(countryKey)) { blockedSources.add(sourceKey) } + + continue + } + + const consentHash = restrictedLists.consentHashPerList[sourceKey] + + if (!consentHash) { + continue + } + + if (!account) { + blockedSources.add(sourceKey) + continue + } + + const consentKey: RwaConsentKey = { + wallet: account, + ipfsHash: consentHash, + } + + if (!getConsentFromCache(consentCache, consentKey)?.acceptedAt) { + blockedSources.add(sourceKey) } } setBlockedListSources(blockedSources) - }, [isRwaGeoblockEnabled, geoStatus, restrictedLists, setBlockedListSources]) + }, [account, consentCache, geoStatus.country, isRwaGeoblockEnabled, restrictedLists, setBlockedListSources]) return null } diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts b/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts index 947b0a10039..1d7ac3a2c23 100644 --- a/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts +++ b/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts @@ -44,6 +44,10 @@ export function useConfirmTradeWithRwaCheck( const confirmTrade = useCallback( (forcePriceConfirmation?: boolean) => { + if (rwaStatus === RwaTokenStatus.ChecksPending || rwaStatus === RwaTokenStatus.Restricted) { + return + } + // Show consent modal if country unknown and consent not given if (rwaStatus === RwaTokenStatus.RequiredConsent && rwaTokenInfo) { openRwaConsentModal({ diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts index d1b6fa67da4..9b1a0f8168b 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts @@ -89,6 +89,7 @@ export function useTradeFormValidationContext(): TradeFormValidationCommonContex inputCurrency, outputCurrency, }) + const isRwaStatusPending = rwaStatus === RwaTokenStatus.ChecksPending const isRestrictedForCountry = rwaStatus === RwaTokenStatus.Restricted return useMemo(() => { @@ -115,6 +116,7 @@ export function useTradeFormValidationContext(): TradeFormValidationCommonContex isProxySetupValid, customTokenError, isRestrictedForCountry, + isRwaStatusPending, isBalancesLoading: !hasFirstLoad || isBalancesLoading, balancesError, injectedWidgetParams, @@ -138,6 +140,7 @@ export function useTradeFormValidationContext(): TradeFormValidationCommonContex isOnline, isProviderNetworkUnsupported, isProviderNetworkDeprecated, + isRwaStatusPending, isRestrictedForCountry, isSafeReadonlyUser, isSupportedWallet, diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx index 0793bada6ba..05ca50213c6 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx @@ -244,6 +244,9 @@ export const tradeButtonsMap: Record, }, + [TradeFormValidation.RwaChecksPending]: { + text: Checking token availability, + }, [TradeFormValidation.RestrictedForCountry]: { text: This token is not available in your region, }, diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.test.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.test.ts index 6e446a8b450..e255c5b6bda 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.test.ts +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.test.ts @@ -50,6 +50,7 @@ describe('validateTradeForm - xStock logic', () => { isAccountProxyLoading: false, isProxySetupValid: true, customTokenError: undefined, + isRwaStatusPending: false, isRestrictedForCountry: false, isBalancesLoading: false, isBundlingSupported: true, @@ -74,6 +75,17 @@ describe('validateTradeForm - xStock logic', () => { expect(result).toContain(TradeFormValidation.XstockMinimumTradeSize) }) + test('blocks trading while RWA availability checks are pending', () => { + const context = { + ...baseContext, + isRwaStatusPending: true, + } as unknown as TradeFormValidationContext + + const result = validateTradeForm(context) + + expect(result).toContain(TradeFormValidation.RwaChecksPending) + }) + test('does not show xStock minimum trade size for sell orders when xStock sell amount is exactly $10', () => { const context = { ...baseContext, @@ -220,6 +232,7 @@ describe('validateTradeForm - price impact loading', () => { isAccountProxyLoading: false, isProxySetupValid: true, customTokenError: undefined, + isRwaStatusPending: false, isRestrictedForCountry: false, isBalancesLoading: false, isBundlingSupported: true, diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts index bf43e1f5001..1f1d8916d88 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts @@ -31,6 +31,7 @@ export function validateTradeForm(context: TradeFormValidationContext): TradeFor isAccountProxyLoading, isProxySetupValid, customTokenError, + isRwaStatusPending, isRestrictedForCountry, isBalancesLoading, isBundlingSupported, @@ -77,6 +78,10 @@ export function validateTradeForm(context: TradeFormValidationContext): TradeFor validations.push(TradeFormValidation.CustomTokenError) } + if (isRwaStatusPending) { + validations.push(TradeFormValidation.RwaChecksPending) + } + if (isRestrictedForCountry) { validations.push(TradeFormValidation.RestrictedForCountry) } diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts index ee97b3d0213..f45d9d43ce2 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts @@ -51,6 +51,7 @@ export interface TradeFormValidationCommonContext { isProxySetupValid: boolean | null | undefined customTokenError?: string isRestrictedForCountry: boolean + isRwaStatusPending: boolean isBalancesLoading: boolean balancesError: string | null isInputCurrencyXstock: boolean @@ -114,6 +115,7 @@ export enum TradeFormValidation { CustomTokenError, // RWA/Geo restrictions + RwaChecksPending, RestrictedForCountry, XstockMinimumTradeSize, diff --git a/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts index 8fc353ebfc5..378f028289e 100644 --- a/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts +++ b/libs/tokens/src/state/tokenLists/tokenListsStateAtom.test.ts @@ -2,8 +2,29 @@ import { createStore } from 'jotai' import { SupportedChainId } from '@cowprotocol/cow-sdk' +jest.mock('@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 +83,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 +176,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 diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx index db37023b606..865325c5c57 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -7,7 +7,11 @@ import { TokenInfo } from '@cowprotocol/types' import { getSourceAsKey } from '../../hooks/lists/useIsListBlocked' import { useRestrictedTokensCache } from '../../hooks/useRestrictedTokensCache' -import { restrictedListsAtom, RestrictedTokenListState } from '../../state/restrictedTokens/restrictedTokensAtom' +import { + restrictedListsAtom, + restrictedTokensAtom, + RestrictedTokenListState, +} from '../../state/restrictedTokens/restrictedTokensAtom' const FETCH_TIMEOUT_MS = 10_000 const MAX_RETRIES = 1 @@ -24,6 +28,13 @@ interface TokenListResponse { tokens: TokenInfo[] } +const PENDING_RESTRICTED_TOKENS_STATE: RestrictedTokenListState = { + tokensMap: {}, + countriesPerToken: {}, + consentHashPerToken: {}, + isLoaded: false, +} + async function fetchWithTimeout(url: string, timeoutMs: number): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeoutMs) @@ -79,13 +90,14 @@ async function fetchTokenList(url: string, retries = MAX_RETRIES): Promise { - if (!isRwaGeoblockEnabled) { + if (isRwaGeoblockEnabled !== true) { return } @@ -102,13 +114,22 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted const consentHashPerToken: Record = {} const blockedCountriesPerList: Record = {} const consentHashPerList: Record = {} + let hasListFetchFailure = false + + for (const list of restrictedLists) { + const urlKey = getSourceAsKey(list.tokenListUrl) + blockedCountriesPerList[urlKey] = list.restrictedCountries + consentHashPerList[urlKey] = RWA_CONSENT_HASH + } + + setRestrictedLists({ + blockedCountriesPerList, + consentHashPerList, + isLoaded: true, + }) await Promise.all( restrictedLists.map(async (list) => { - const urlKey = getSourceAsKey(list.tokenListUrl) - blockedCountriesPerList[urlKey] = list.restrictedCountries - consentHashPerList[urlKey] = RWA_CONSENT_HASH - try { const tokens = await fetchTokenList(list.tokenListUrl) @@ -119,11 +140,17 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted consentHashPerToken[tokenId] = RWA_CONSENT_HASH } } catch (error) { + hasListFetchFailure = true console.error(`Failed to fetch token list for ${list.name}:`, error) } }), ) + if (hasListFetchFailure) { + setRestrictedTokens(PENDING_RESTRICTED_TOKENS_STATE) + return + } + const listState: RestrictedTokenListState = { tokensMap, countriesPerToken, @@ -131,20 +158,15 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted isLoaded: true, } - setRestrictedLists({ - blockedCountriesPerList, - consentHashPerList, - isLoaded: true, - }) - saveToCache(listState) } catch (error) { + setRestrictedTokens(PENDING_RESTRICTED_TOKENS_STATE) console.error('Error loading restricted tokens:', error) } } loadRestrictedTokens() - }, [isRwaGeoblockEnabled, shouldFetch, saveToCache, setRestrictedLists]) + }, [isRwaGeoblockEnabled, saveToCache, setRestrictedLists, setRestrictedTokens, shouldFetch]) return null } diff --git a/libs/tokens/src/updaters/TokensListsUpdater/curatedMode.test.ts b/libs/tokens/src/updaters/TokensListsUpdater/curatedMode.test.ts new file mode 100644 index 00000000000..e41b4ce46f8 --- /dev/null +++ b/libs/tokens/src/updaters/TokensListsUpdater/curatedMode.test.ts @@ -0,0 +1,11 @@ +import { shouldInvalidateLastUpdateTime } from './curatedMode' + +describe('TokensListsUpdater curated mode', () => { + it('invalidates the cache only when curated mode expands back to the full source set', () => { + expect(shouldInvalidateLastUpdateTime(true, false)).toBe(true) + expect(shouldInvalidateLastUpdateTime(undefined, false)).toBe(false) + expect(shouldInvalidateLastUpdateTime(false, false)).toBe(false) + expect(shouldInvalidateLastUpdateTime(false, true)).toBe(false) + expect(shouldInvalidateLastUpdateTime(true, true)).toBe(false) + }) +}) diff --git a/libs/tokens/src/updaters/TokensListsUpdater/curatedMode.ts b/libs/tokens/src/updaters/TokensListsUpdater/curatedMode.ts new file mode 100644 index 00000000000..4cb6b168849 --- /dev/null +++ b/libs/tokens/src/updaters/TokensListsUpdater/curatedMode.ts @@ -0,0 +1,6 @@ +export function shouldInvalidateLastUpdateTime( + previousCuratedMode: boolean | undefined, + nextCuratedMode: boolean, +): boolean { + return previousCuratedMode === true && nextCuratedMode === false +} diff --git a/libs/tokens/src/updaters/TokensListsUpdater/index.test.tsx b/libs/tokens/src/updaters/TokensListsUpdater/index.test.tsx new file mode 100644 index 00000000000..226dfe92e8e --- /dev/null +++ b/libs/tokens/src/updaters/TokensListsUpdater/index.test.tsx @@ -0,0 +1,157 @@ +import { createStore, Provider } from 'jotai' +import { ReactNode } from 'react' + +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { act, render, waitFor } from '@testing-library/react' + +jest.mock('@cowprotocol/common-utils', () => { + const { atom } = require('jotai') + + return { + atomWithPartialUpdate(baseAtom) { + const updateAtom = atom(null, (get, set, update) => { + set(baseAtom, { ...get(baseAtom), ...update }) + }) + + return { atom: baseAtom, updateAtom } + }, + } +}) + +jest.mock('@cowprotocol/core', () => ({ + getJotaiMergerStorage: jest.fn(), +})) + +jest.mock('./helpers', () => ({ + getFulfilledResults: jest.fn(), + getIsTimeToUpdate: jest.fn(() => false), + TOKENS_LISTS_UPDATER_INTERVAL: 21_600_000, +})) + +jest.mock('../../services/fetchTokenList', () => ({ + fetchTokenList: jest.fn(), +})) + +jest.mock('../../state/tokenLists/tokenListsActionsAtom', () => { + const { atom } = require('jotai') + + return { + upsertListsAtom: atom(null, jest.fn()), + } +}) + +jest.mock('../../state/tokenLists/tokenListsStateAtom', () => { + const { atom } = require('jotai') + + return { + allListsSourcesAtom: atom([]), + tokenListsUpdatingAtom: atom(false), + } +}) + +jest.mock('../../state/environmentAtom', () => { + const { atom } = require('jotai') + const { SupportedChainId } = require('@cowprotocol/cow-sdk') + + const environmentAtom = atom({ + chainId: SupportedChainId.MAINNET, + }) + const updateEnvironmentAtom = atom(null, (get, set, update) => { + set(environmentAtom, { ...get(environmentAtom), ...update }) + }) + + return { + environmentAtom, + updateEnvironmentAtom, + } +}) + +import { environmentAtom } from '../../state/environmentAtom' + +import { TokensListsUpdater } from '.' + +const mockUseSWR = jest.fn(() => ({ data: null, isLoading: false })) + +jest.mock('@sentry/browser', () => ({ + captureException: jest.fn(), +})) + +jest.mock('swr', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseSWR(...args), +})) + +jest.mock('../UserAddedTokensUpdater', () => ({ + UserAddedTokensUpdater: () => null, +})) + +describe('TokensListsUpdater', () => { + const originalFetch = global.fetch + + beforeEach(() => { + jest.clearAllMocks() + mockUseSWR.mockReturnValue({ data: null, isLoading: false }) + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it('ignores stale geo responses after geoblocking is disabled', async () => { + const store = createStore() + let resolveGeoRequest: ((value: { json: () => Promise<{ country: string }> }) => void) | undefined + + global.fetch = jest.fn( + () => + new Promise((resolve) => { + resolveGeoRequest = resolve + }), + ) as typeof fetch + + const wrapper = ({ children }: { children: ReactNode }): ReactNode => {children} + + const view = render( + , + { wrapper }, + ) + + await waitFor(() => { + expect(store.get(environmentAtom)).toMatchObject({ + chainId: SupportedChainId.MAINNET, + useCuratedListOnly: true, + }) + }) + + view.rerender( + , + ) + + await waitFor(() => { + expect(store.get(environmentAtom).useCuratedListOnly).toBe(false) + }) + + await act(async () => { + resolveGeoRequest?.({ + json: () => Promise.resolve({ country: 'US' }), + }) + await Promise.resolve() + }) + + await waitFor(() => { + expect(store.get(environmentAtom).useCuratedListOnly).toBe(false) + }) + }) +}) diff --git a/libs/tokens/src/updaters/TokensListsUpdater/index.tsx b/libs/tokens/src/updaters/TokensListsUpdater/index.tsx index a9e9997d7fa..9360e3f5e0b 100644 --- a/libs/tokens/src/updaters/TokensListsUpdater/index.tsx +++ b/libs/tokens/src/updaters/TokensListsUpdater/index.tsx @@ -1,8 +1,8 @@ import { useAtomValue, useSetAtom } from 'jotai' import { atomWithStorage } from 'jotai/utils' -import { ReactNode, useEffect } from 'react' +import { ReactNode, useEffect, useRef } from 'react' -import { atomWithPartialUpdate, isInjectedWidget } from '@cowprotocol/common-utils' +import { atomWithPartialUpdate } from '@cowprotocol/common-utils' import { getJotaiMergerStorage } from '@cowprotocol/core' import { ChainInfo, mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' import { PersistentStateByChain } from '@cowprotocol/types' @@ -10,6 +10,7 @@ import { PersistentStateByChain } from '@cowprotocol/types' import * as Sentry from '@sentry/browser' import useSWR, { SWRConfiguration } from 'swr' +import { shouldInvalidateLastUpdateTime } from './curatedMode' import { getFulfilledResults, getIsTimeToUpdate, TOKENS_LISTS_UPDATER_INTERVAL } from './helpers' import { fetchTokenList } from '../../services/fetchTokenList' @@ -41,7 +42,7 @@ const NETWORKS_WITHOUT_RESTRICTIONS: SupportedChainId[] = [SupportedChainId.SEPO interface TokensListsUpdaterProps { chainId: SupportedChainId - isGeoBlockEnabled: boolean + isGeoBlockEnabled: boolean | undefined enableLpTokensByDefault: boolean isYieldEnabled: boolean bridgeNetworkInfo: ChainInfo[] | undefined @@ -68,6 +69,7 @@ export function TokensListsUpdater({ const allTokensLists = useAtomValue(allListsSourcesAtom) const lastUpdateTimeState = useAtomValue(lastUpdateTimeAtom) const updateLastUpdateTime = useSetAtom(updateLastUpdateTimeAtom) + const previousCuratedModeRef = useRef(undefined) const setTokenListsUpdating = useSetAtom(tokenListsUpdatingAtom) const upsertLists = useSetAtom(upsertListsAtom) @@ -80,6 +82,17 @@ export function TokensListsUpdater({ updateLastUpdateTime({ [chainId]: 0 }) }, [chainId, updateLastUpdateTime]) + function setCuratedListOnly(nextValue: boolean): void { + setEnvironment({ useCuratedListOnly: nextValue }) + + // When we widen the source set again, force a refetch for the newly visible lists. + if (shouldInvalidateLastUpdateTime(previousCuratedModeRef.current, nextValue)) { + updateLastUpdateTime({ [chainId]: 0 }) + } + + previousCuratedModeRef.current = nextValue + } + // Fetch tokens lists once in 6 hours const { data: listsStates, isLoading } = useSWR( ['TokensListsUpdater', allTokensLists, chainId, lastUpdateTimeState], @@ -104,24 +117,39 @@ export function TokensListsUpdater({ // Check if a user is from US and use Uniswap list, because of the SEC regulations useEffect(() => { - if (!isGeoBlockEnabled || isInjectedWidget()) return - if (NETWORKS_WITHOUT_RESTRICTIONS.includes(chainId)) { - setEnvironment({ useCuratedListOnly: false }) + setCuratedListOnly(false) + return + } + + if (isGeoBlockEnabled === false) { + setCuratedListOnly(false) return } - fetch('https://api.country.is') + setCuratedListOnly(true) + + let isStale = false + const controller = new AbortController() + + fetch('https://api.country.is', { signal: controller.signal }) .then((res) => res.json()) .then(({ country }) => { + if (isStale) return + const isUsUser = country === 'US' + setCuratedListOnly(isUsUser) + if (isUsUser) { - setEnvironment({ useCuratedListOnly: true }) updateLastUpdateTime({ [chainId]: 0 }) } }) .catch((error) => { + if (isStale) return + + setCuratedListOnly(true) + if (GEOBLOCK_ERRORS_TO_IGNORE.test(error?.toString())) return const sentryError = Object.assign(error, { @@ -134,6 +162,11 @@ export function TokensListsUpdater({ }, }) }) + + return () => { + isStale = true + controller.abort() + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [chainId, isGeoBlockEnabled])