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 here0>. 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 here0>. 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])