From a960e98ff5a4b89e571ee2798d123497ea89dc3d Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 25 Dec 2025 20:55:30 +0400 Subject: [PATCH 01/25] feat: implement consent for all forms --- .../containers/TradeButtons/index.tsx | 5 +- .../swap/containers/TradeButtons/index.tsx | 32 +------- .../hooks/useConfirmTradeWithRwaCheck.ts | 76 +++++++++++++++++++ .../src/modules/trade/index.ts | 1 + .../twap/containers/ActionButtons/index.tsx | 29 +++---- 5 files changed, 95 insertions(+), 48 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeButtons/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeButtons/index.tsx index b3826a95e1d..6b8f3d585d7 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeButtons/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeButtons/index.tsx @@ -4,7 +4,7 @@ import { MessageDescriptor } from '@lingui/core' import { useLingui } from '@lingui/react/macro' import { useLimitOrdersWarningsAccepted } from 'modules/limitOrders/hooks/useLimitOrdersWarningsAccepted' -import { useTradeConfirmActions } from 'modules/trade' +import { useConfirmTradeWithRwaCheck } from 'modules/trade' import { TradeFormBlankButton, TradeFormButtons, @@ -34,9 +34,8 @@ export function TradeButtons({ isTradeContextReady }: TradeButtonsProps) { const localFormValidation = useLimitOrdersFormState() const primaryFormValidation = useGetTradeFormValidation() const warningsAccepted = useLimitOrdersWarningsAccepted(false) - const tradeConfirmActions = useTradeConfirmActions() - const confirmTrade = tradeConfirmActions.onOpen + const { confirmTrade } = useConfirmTradeWithRwaCheck() const tradeFormButtonContext = useTradeFormButtonContext(CONFIRM_TEXT, confirmTrade) diff --git a/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx b/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx index 64c1b649846..fa8149a9a9c 100644 --- a/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx +++ b/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useCallback } from 'react' +import React, { ReactNode } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' @@ -6,12 +6,11 @@ import { useIsSafeWallet } from '@cowprotocol/wallet' import { useLingui } from '@lingui/react/macro' -import { useRwaConsentModalState, RwaTokenStatus, useRwaTokenStatus } from 'modules/rwa' import { AddIntermediateToken } from 'modules/tokensList' import { + useConfirmTradeWithRwaCheck, useIsCurrentTradeBridging, useIsNoImpactWarningAccepted, - useTradeConfirmActions, useWrappedToken, } from 'modules/trade' import { @@ -46,7 +45,6 @@ interface TradeButtonsProps { setShowAddIntermediateTokenModal: (show: boolean) => void } -// eslint-disable-next-line max-lines-per-function export function TradeButtons({ isTradeContextReady, openNativeWrapModal, @@ -55,12 +53,10 @@ export function TradeButtons({ intermediateBuyToken, setShowAddIntermediateTokenModal, }: TradeButtonsProps): ReactNode { - const { inputCurrency, outputCurrency } = useSwapDerivedState() - const { openModal: openRwaConsentModal } = useRwaConsentModalState() + const { inputCurrency } = useSwapDerivedState() const primaryFormValidation = useGetTradeFormValidation() const isPrimaryValidationPassed = useIsTradeFormValidationPassed() - const tradeConfirmActions = useTradeConfirmActions() const { feeWarningAccepted } = useHighFeeWarning() const isNoImpactWarningAccepted = useIsNoImpactWarningAccepted() const localFormValidation = useSwapFormState() @@ -73,27 +69,7 @@ export function TradeButtons({ const { t } = useLingui() - // Check RWA token status for consent modal - const { status: rwaStatus, rwaTokenInfo } = useRwaTokenStatus({ - inputCurrency, - outputCurrency, - }) - - const confirmTrade = useCallback( - (forcePriceConfirmation?: boolean) => { - // Show consent modal if country unknown and consent not given - if (rwaStatus === RwaTokenStatus.RequiredConsent && rwaTokenInfo) { - openRwaConsentModal({ - consentHash: rwaTokenInfo.consentHash, - token: TokenWithLogo.fromToken(rwaTokenInfo.token), - }) - return - } - - tradeConfirmActions.onOpen(forcePriceConfirmation) - }, - [rwaStatus, rwaTokenInfo, openRwaConsentModal, tradeConfirmActions], - ) + const { confirmTrade } = useConfirmTradeWithRwaCheck() const confirmText = isCurrentTradeBridging ? t`Swap and Bridge` : t`Swap` diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts b/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts new file mode 100644 index 00000000000..fa29280e31c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts @@ -0,0 +1,76 @@ +import { useCallback, useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { RwaTokenStatus, useRwaConsentModalState, useRwaTokenStatus, RwaTokenInfo } from 'modules/rwa' + +import { useDerivedTradeState } from './useDerivedTradeState' +import { TradeConfirmActions, useTradeConfirmActions } from './useTradeConfirmActions' + +export interface ConfirmTradeWithRwaCheckResult { + confirmTrade: (forcePriceConfirmation?: boolean) => void + rwaStatus: RwaTokenStatus + rwaTokenInfo: RwaTokenInfo | null + tradeConfirmActions: TradeConfirmActions +} + +export interface UseConfirmTradeWithRwaCheckParams { + /** + * Optional callback that's called when the trade confirmation is actually opened + * (not when the consent modal is shown). + * Use this for analytics or other side effects that should only happen on actual confirmation. + */ + onConfirmOpen?: () => void +} + +/** + * Hook that wraps trade confirmation with RWA token consent check. + * + * If the user's country is unknown and they haven't given consent for a restricted token, + * this will open the RWA consent modal instead of the trade confirmation modal. + * After consent is given, the trade confirmation will proceed. + * + * Use this hook in all trade flows (swap, limit orders, TWAP) to ensure consistent + */ +export function useConfirmTradeWithRwaCheck( + params: UseConfirmTradeWithRwaCheckParams = {}, +): ConfirmTradeWithRwaCheckResult { + const { onConfirmOpen } = params + + const derivedState = useDerivedTradeState() + const { inputCurrency, outputCurrency } = derivedState || {} + + const { status: rwaStatus, rwaTokenInfo } = useRwaTokenStatus({ + inputCurrency, + outputCurrency, + }) + const { openModal: openRwaConsentModal } = useRwaConsentModalState() + const tradeConfirmActions = useTradeConfirmActions() + + const confirmTrade = useCallback( + (forcePriceConfirmation?: boolean) => { + // Show consent modal if country unknown and consent not given + if (rwaStatus === RwaTokenStatus.RequiredConsent && rwaTokenInfo) { + openRwaConsentModal({ + consentHash: rwaTokenInfo.consentHash, + token: TokenWithLogo.fromToken(rwaTokenInfo.token), + }) + return + } + + onConfirmOpen?.() + tradeConfirmActions.onOpen(forcePriceConfirmation) + }, + [rwaStatus, rwaTokenInfo, openRwaConsentModal, tradeConfirmActions, onConfirmOpen], + ) + + return useMemo( + () => ({ + confirmTrade, + rwaStatus, + rwaTokenInfo, + tradeConfirmActions, + }), + [confirmTrade, rwaStatus, rwaTokenInfo, tradeConfirmActions], + ) +} diff --git a/apps/cowswap-frontend/src/modules/trade/index.ts b/apps/cowswap-frontend/src/modules/trade/index.ts index 5814c2cfa1e..58c80f23b38 100644 --- a/apps/cowswap-frontend/src/modules/trade/index.ts +++ b/apps/cowswap-frontend/src/modules/trade/index.ts @@ -7,6 +7,7 @@ export * from './containers/TradeBasicConfirmDetails' export * from './const/common' export * from './pure/TradeConfirmation' export * from './hooks/useTradeConfirmActions' +export * from './hooks/useConfirmTradeWithRwaCheck' export * from './hooks/useTradeTypeInfo' export * from './hooks/useTradePriceImpact' export * from './hooks/setupTradeState/useSetupTradeState' diff --git a/apps/cowswap-frontend/src/modules/twap/containers/ActionButtons/index.tsx b/apps/cowswap-frontend/src/modules/twap/containers/ActionButtons/index.tsx index e4b9eeb0fdd..860e4461e75 100644 --- a/apps/cowswap-frontend/src/modules/twap/containers/ActionButtons/index.tsx +++ b/apps/cowswap-frontend/src/modules/twap/containers/ActionButtons/index.tsx @@ -1,10 +1,10 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' import { t } from '@lingui/core/macro' -import { useTradeConfirmActions } from 'modules/trade' +import { useConfirmTradeWithRwaCheck } from 'modules/trade' import { TradeFormButtons, TradeFormValidation, useTradeFormButtonContext } from 'modules/tradeFormValidation' import { CowSwapAnalyticsCategory } from 'common/analytics/types' @@ -27,25 +27,20 @@ export function ActionButtons({ primaryFormValidation, fallbackHandlerIsNotSet, }: ActionButtonsProps) { - const tradeConfirmActions = useTradeConfirmActions() const { walletIsNotConnected } = useTwapWarningsContext() const cowAnalytics = useCowAnalytics() - const twapConversionAnalytics = useCallback( - (status: string, fallbackHandlerIsNotSet: boolean) => { - cowAnalytics.sendEvent({ - category: CowSwapAnalyticsCategory.TWAP, - action: 'Conversion', - label: `${status}|${fallbackHandlerIsNotSet ? 'no-handler' : 'handler-set'}`, - }) - }, - [cowAnalytics], - ) + // Analytics callback that fires only when trade confirmation is actually opened + const onConfirmOpen = useCallback(() => { + cowAnalytics.sendEvent({ + category: CowSwapAnalyticsCategory.TWAP, + action: 'Conversion', + label: `initiated|${fallbackHandlerIsNotSet ? 'no-handler' : 'handler-set'}`, + }) + }, [cowAnalytics, fallbackHandlerIsNotSet]) - const confirmTrade = useCallback(() => { - tradeConfirmActions.onOpen() - twapConversionAnalytics('initiated', fallbackHandlerIsNotSet) - }, [tradeConfirmActions, twapConversionAnalytics, fallbackHandlerIsNotSet]) + const hookParams = useMemo(() => ({ onConfirmOpen }), [onConfirmOpen]) + const { confirmTrade } = useConfirmTradeWithRwaCheck(hookParams) const areWarningsAccepted = useAreWarningsAccepted() From 9bad188ae07b8cbbecb1e7815e80b59b7543790d Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 25 Dec 2025 22:12:14 +0400 Subject: [PATCH 02/25] feat: add cache for restricted token list --- .../src/hooks/useRestrictedTokensCache.ts | 61 +++++++++++++++++++ .../restrictedTokens/restrictedTokensAtom.ts | 21 +++++++ .../RestrictedTokensListUpdater/index.tsx | 21 +++---- 3 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 libs/tokens/src/hooks/useRestrictedTokensCache.ts diff --git a/libs/tokens/src/hooks/useRestrictedTokensCache.ts b/libs/tokens/src/hooks/useRestrictedTokensCache.ts new file mode 100644 index 00000000000..9d7bae32dde --- /dev/null +++ b/libs/tokens/src/hooks/useRestrictedTokensCache.ts @@ -0,0 +1,61 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { useCallback, useEffect, useRef } from 'react' + +import ms from 'ms.macro' + +import { + RestrictedTokenListState, + restrictedTokensAtom, + restrictedTokensCacheAtom, + restrictedTokensLastUpdateAtom, +} from '../state/restrictedTokens/restrictedTokensAtom' + +const UPDATE_INTERVAL = ms`6h` + +function isTimeToUpdate(lastUpdateTime: number): boolean { + if (!lastUpdateTime) return true + return Date.now() - lastUpdateTime > UPDATE_INTERVAL +} + +interface UseRestrictedTokensCacheResult { + shouldFetch: boolean + saveToCache: (state: RestrictedTokenListState) => void +} + +export function useRestrictedTokensCache(): UseRestrictedTokensCacheResult { + const setRestrictedTokens = useSetAtom(restrictedTokensAtom) + const setCache = useSetAtom(restrictedTokensCacheAtom) + const cachedState = useAtomValue(restrictedTokensCacheAtom) + const runtimeState = useAtomValue(restrictedTokensAtom) + const lastUpdateTime = useAtomValue(restrictedTokensLastUpdateAtom) + const setLastUpdateTime = useSetAtom(restrictedTokensLastUpdateAtom) + + const hasLoadedFromCache = useRef(false) + + // load cached data from IndexedDB into runtime state on mount + useEffect(() => { + if (cachedState.isLoaded && !hasLoadedFromCache.current) { + hasLoadedFromCache.current = true + setRestrictedTokens(cachedState) + } + }, [cachedState, setRestrictedTokens]) + + const saveToCache = useCallback( + (state: RestrictedTokenListState) => { + setRestrictedTokens(state) + setCache(state) + setLastUpdateTime(Date.now()) + }, + [setRestrictedTokens, setCache, setLastUpdateTime], + ) + + // Should fetch if: + // 1. Time-based update is needed, OR + // 2. Runtime state is not loaded (no data available yet) + const shouldFetch = isTimeToUpdate(lastUpdateTime) || !runtimeState.isLoaded + + return { + shouldFetch, + saveToCache, + } +} diff --git a/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts b/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts index 2a16ebf9b6a..be4166f64a5 100644 --- a/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts +++ b/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts @@ -1,6 +1,8 @@ import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' import { getTokenId, TokenId } from '@cowprotocol/common-utils' +import { atomWithIdbStorage, getJotaiMergerStorage } from '@cowprotocol/core' import { TokenInfo } from '@cowprotocol/types' export { getTokenId } @@ -20,4 +22,23 @@ const initialState: RestrictedTokenListState = { isLoaded: false, } +/** + * Persisted cache in IndexedDB - loaded asynchronously on app start + */ +export const restrictedTokensCacheAtom = atomWithIdbStorage( + 'restrictedTokens:v1', + initialState, +) + +/** + * Runtime state for synchronous access by hooks. + * Populated from cache on mount, updated by the updater. + */ export const restrictedTokensAtom = atom(initialState) + +export const restrictedTokensLastUpdateAtom = atomWithStorage( + 'restrictedTokens:lastUpdate:v1', + 0, + getJotaiMergerStorage(), + { unstable_getOnInit: true }, +) diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx index e28bb05d5c9..23a2fd1b04a 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -1,15 +1,10 @@ -import { useSetAtom } from 'jotai' import { useEffect } from 'react' import { getRestrictedTokenLists } from '@cowprotocol/core' import { TokenInfo } from '@cowprotocol/types' -import { - getTokenId, - RestrictedTokenListState, - restrictedTokensAtom, - TokenId, -} from '../../state/restrictedTokens/restrictedTokensAtom' +import { useRestrictedTokensCache } from '../../hooks/useRestrictedTokensCache' +import { getTokenId, RestrictedTokenListState, TokenId } from '../../state/restrictedTokens/restrictedTokensAtom' const FETCH_TIMEOUT_MS = 10_000 const MAX_RETRIES = 1 @@ -79,14 +74,18 @@ async function fetchTokenList(url: string, retries = MAX_RETRIES): Promise { - // Don't load restricted tokens if RWA geoblock feature is disabled if (!isRwaGeoblockEnabled) { return } + // Skip if cache is still valid AND we have data + if (!shouldFetch) { + return + } + async function loadRestrictedTokens(): Promise { try { const restrictedLists = await getRestrictedTokenLists() @@ -119,14 +118,14 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted isLoaded: true, } - setRestrictedTokens(listState) + saveToCache(listState) } catch (error) { console.error('Error loading restricted tokens:', error) } } loadRestrictedTokens() - }, [setRestrictedTokens, isRwaGeoblockEnabled]) + }, [isRwaGeoblockEnabled, shouldFetch, saveToCache]) return null } From 8ac48ebe22a097bddc4e7e467fc308b30af58670 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 25 Dec 2025 22:53:23 +0400 Subject: [PATCH 03/25] chore: remove unused --- .../src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts b/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts index fa29280e31c..947b0a10039 100644 --- a/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts +++ b/apps/cowswap-frontend/src/modules/trade/hooks/useConfirmTradeWithRwaCheck.ts @@ -15,11 +15,6 @@ export interface ConfirmTradeWithRwaCheckResult { } export interface UseConfirmTradeWithRwaCheckParams { - /** - * Optional callback that's called when the trade confirmation is actually opened - * (not when the consent modal is shown). - * Use this for analytics or other side effects that should only happen on actual confirmation. - */ onConfirmOpen?: () => void } From 94afb026900b31103f6b91b0232b5a0a4cdf99ba Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 25 Dec 2025 23:07:53 +0400 Subject: [PATCH 04/25] chore: remove unused --- libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx index 23a2fd1b04a..37e46b17758 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -81,7 +81,6 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted return } - // Skip if cache is still valid AND we have data if (!shouldFetch) { return } From b292fb249a9b956d38ad6542eb0fb05522793bb3 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 25 Dec 2025 23:17:20 +0400 Subject: [PATCH 05/25] feat: remove i18n for consents --- apps/cowswap-frontend/src/locales/en-US.po | 45 +++++++------- .../RwaConsentModalContainer/index.tsx | 9 +-- .../rwa/pure/RwaConsentModal/index.tsx | 59 +++++-------------- .../rwa/pure/RwaConsentModal/styled.ts | 21 ------- 4 files changed, 37 insertions(+), 97 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index cd3bb261d39..8b99b4ebc5f 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -236,8 +236,8 @@ msgid "Your TWAP order won't execute and is protected if the market price dips m msgstr "Your TWAP order won't execute and is protected if the market price dips more than your set price protection." #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "If you fall into any of these categories, select Cancel." -msgstr "If you fall into any of these categories, select Cancel." +#~ msgid "If you fall into any of these categories, select Cancel." +#~ msgstr "If you fall into any of these categories, select Cancel." #: apps/cowswap-frontend/src/api/cowProtocol/errors/OperatorError.ts msgid "The order cannot be {statusText}. Your account is deny-listed." @@ -542,8 +542,8 @@ msgid "(modified)" msgstr "(modified)" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "A resident of any jurisdiction where trading securities or cryptographic tokens is regulated or prohibited by applicable laws." -msgstr "A resident of any jurisdiction where trading securities or cryptographic tokens is regulated or prohibited by applicable laws." +#~ msgid "A resident of any jurisdiction where trading securities or cryptographic tokens is regulated or prohibited by applicable laws." +#~ msgstr "A resident of any jurisdiction where trading securities or cryptographic tokens is regulated or prohibited by applicable laws." #: apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts #: apps/cowswap-frontend/src/modules/twap/const.ts @@ -583,8 +583,8 @@ msgid "When selling {aNativeCurrency}, the minimum slippage tolerance is set to msgstr "When selling {aNativeCurrency}, the minimum slippage tolerance is set to {minimumETHFlowSlippage}% or higher to ensure a high likelihood of order matching, even in volatile market conditions." #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "A U.S. Person or resident of the United States." -msgstr "A U.S. Person or resident of the United States." +#~ msgid "A U.S. Person or resident of the United States." +#~ msgstr "A U.S. Person or resident of the United States." #: apps/cowswap-frontend/src/modules/account/pure/ConnectedAccountBlocked/index.tsx msgid "If you believe this is an error, please send an email including your address to " @@ -1053,8 +1053,8 @@ msgid "Orders history" msgstr "Orders history" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "We could not reliably determine your location (e.g., due to VPN or privacy settings). Access to {displaySymbol} is strictly limited to specific regions." -msgstr "We could not reliably determine your location (e.g., due to VPN or privacy settings). Access to {displaySymbol} is strictly limited to specific regions." +#~ msgid "We could not reliably determine your location (e.g., due to VPN or privacy settings). Access to {displaySymbol} is strictly limited to specific regions." +#~ msgstr "We could not reliably determine your location (e.g., due to VPN or privacy settings). Access to {displaySymbol} is strictly limited to specific regions." #: apps/cowswap-frontend/src/modules/erc20Approve/pure/ApprovalTooltip/index.tsx msgid "You must give the CoW Protocol smart contracts permission to use your <0/>." @@ -1365,8 +1365,8 @@ msgid "Please read more in this" msgstr "Please read more in this" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "Additional confirmation required for this token" -msgstr "Additional confirmation required for this token" +#~ msgid "Additional confirmation required for this token" +#~ msgstr "Additional confirmation required for this token" #: libs/hook-dapp-lib/src/hookDappsRegistry.ts #~ msgid "Bungee" @@ -1530,8 +1530,8 @@ msgid "Enter a hook dapp URL" msgstr "Enter a hook dapp URL" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "By clicking Confirm, you expressly represent and warrant that you are NOT:" -msgstr "By clicking Confirm, you expressly represent and warrant that you are NOT:" +#~ msgid "By clicking Confirm, you expressly represent and warrant that you are NOT:" +#~ msgstr "By clicking Confirm, you expressly represent and warrant that you are NOT:" #: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx #: apps/cowswap-frontend/src/modules/trade/pure/TotalFeeRow/index.tsx @@ -1891,8 +1891,8 @@ msgid "Failed to cancel order selling {sellTokenSymbol}" msgstr "Failed to cancel order selling {sellTokenSymbol}" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "You are solely responsible for complying with your local laws." -msgstr "You are solely responsible for complying with your local laws." +#~ msgid "You are solely responsible for complying with your local laws." +#~ msgstr "You are solely responsible for complying with your local laws." #: apps/cowswap-frontend/src/modules/swap/containers/SwapConfirmModal/useLabelsAndTooltips.tsx #: apps/cowswap-frontend/src/modules/trade/containers/TradeBasicConfirmDetails/index.tsx @@ -2007,8 +2007,8 @@ msgid "Creating..." msgstr "Creating..." #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "A resident of any country subject to international sanctions (e.g., OFAC, UN lists)." -msgstr "A resident of any country subject to international sanctions (e.g., OFAC, UN lists)." +#~ msgid "A resident of any country subject to international sanctions (e.g., OFAC, UN lists)." +#~ msgstr "A resident of any country subject to international sanctions (e.g., OFAC, UN lists)." #: apps/cowswap-frontend/src/modules/hooksStore/pure/HookDappDetails/index.tsx msgid "{typeLabel} hooks are externally hosted code which needs to be independently verified by the user." @@ -3423,8 +3423,8 @@ msgid "Wrap <0/> and Swap" msgstr "Wrap <0/> and Swap" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "View full consent terms ↗" -msgstr "View full consent terms ↗" +#~ msgid "View full consent terms ↗" +#~ msgstr "View full consent terms ↗" #: apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/warnings/BigPartTimeWarning.tsx msgid "A maximum of <0>{time} between parts is required. Increase the number of parts or decrease the total duration." @@ -3613,8 +3613,8 @@ msgid "Approving <0>{currencySymbolOrContext} for trading" msgstr "Approving <0>{currencySymbolOrContext} for trading" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "A resident of the EU or EEA." -msgstr "A resident of the EU or EEA." +#~ msgid "A resident of the EU or EEA." +#~ msgstr "A resident of the EU or EEA." #: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx msgid "Couldn't load balances" @@ -4185,7 +4185,6 @@ msgstr "Governance" #: apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/index.tsx #: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/index.tsx #: apps/cowswap-frontend/src/modules/ordersTable/containers/MultipleCancellationMenu/index.tsx -#: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx #: apps/cowswap-frontend/src/modules/twap/pure/CustomDeadlineSelector/index.tsx msgid "Cancel" msgstr "Cancel" @@ -5962,8 +5961,8 @@ msgid "User rejected signing COW claim transaction" msgstr "User rejected signing COW claim transaction" #: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx -msgid "I Confirm" -msgstr "I Confirm" +#~ msgid "I Confirm" +#~ msgstr "I Confirm" #: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx #: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/index.tsx diff --git a/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx b/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx index 982f8c1a15d..26600b140a1 100644 --- a/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx @@ -44,12 +44,5 @@ export function RwaConsentModalContainer(): ReactNode { return null } - return ( - - ) + return } diff --git a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx index 33e3257741a..7099f509f8e 100644 --- a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx @@ -4,30 +4,22 @@ import { TokenWithLogo } from '@cowprotocol/common-const' import { TokenLogo } from '@cowprotocol/tokens' import { ButtonPrimary, ButtonOutlined, ModalHeader } from '@cowprotocol/ui' -import { Trans } from '@lingui/react/macro' - import * as styledEl from './styled' export interface RwaConsentModalProps { onDismiss(): void onConfirm(): void token?: TokenWithLogo - consentHash: string } -const IPFS_GATEWAY = 'https://ipfs.io/ipfs' - export function RwaConsentModal(props: RwaConsentModalProps): ReactNode { - const { onDismiss, onConfirm, token, consentHash } = props + const { onDismiss, onConfirm, token } = props const displaySymbol = token?.symbol || 'this token' - const consentUrl = `${IPFS_GATEWAY}/${consentHash}` return ( - - Additional confirmation required for this token - + Additional confirmation required for this token {token && ( @@ -45,50 +37,27 @@ export function RwaConsentModal(props: RwaConsentModalProps): ReactNode { )}

- - We could not reliably determine your location (e.g., due to VPN or privacy settings). Access to{' '} - {displaySymbol} is strictly limited to specific regions. - + We could not reliably determine your location (e.g., due to VPN or privacy settings). Access to{' '} + {displaySymbol} is strictly limited to specific regions.

-

- By clicking Confirm, you expressly represent and warrant that you are NOT: -

+

By clicking Confirm, you expressly represent and warrant that you are NOT:

+
  • A U.S. Person or resident of the United States.
  • +
  • A resident of the EU or EEA.
  • +
  • A resident of any country subject to international sanctions (e.g., OFAC, UN lists).
  • - A U.S. Person or resident of the United States. -
  • -
  • - A resident of the EU or EEA. -
  • -
  • - A resident of any country subject to international sanctions (e.g., OFAC, UN lists). -
  • -
  • - - A resident of any jurisdiction where trading securities or cryptographic tokens is regulated or - prohibited by applicable laws. - + A resident of any jurisdiction where trading securities or cryptographic tokens is regulated or + prohibited by applicable laws.
  • -

    - If you fall into any of these categories, select Cancel. -

    -

    - You are solely responsible for complying with your local laws. -

    - - View full consent terms ↗ - +

    If you fall into any of these categories, select Cancel.

    +

    You are solely responsible for complying with your local laws.

    - - I Confirm - - - Cancel - + I Confirm + Cancel
    diff --git a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/styled.ts b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/styled.ts index 78558b93296..e11cf1542b9 100644 --- a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/styled.ts @@ -100,24 +100,3 @@ export const ButtonContainer = styled.div` min-height: 56px; } ` - -export const ConsentLink = styled.a` - display: inline-flex; - align-items: center; - gap: 4px; - color: var(${UI.COLOR_PRIMARY}); - font-size: 13px; - text-decoration: none; - opacity: 0.8; - transition: opacity 0.15s ease-in-out; - - &:hover { - opacity: 1; - text-decoration: underline; - } - - > svg { - width: 12px; - height: 12px; - } -` From 4e98cf3afca115c561f00921230bdc67a210748e Mon Sep 17 00:00:00 2001 From: limitofzero Date: Fri, 26 Dec 2025 00:12:47 +0400 Subject: [PATCH 06/25] feat: show conesnts before import modal --- .../RwaConsentModalContainer/index.tsx | 24 +++++-- .../rwa/hooks/useRwaConsentModalState.ts | 8 +-- .../cowswap-frontend/src/modules/rwa/index.ts | 1 + .../rwa/pure/RwaConsentModal/index.tsx | 1 + .../rwa/state/rwaConsentModalStateAtom.ts | 12 ++-- .../containers/SelectTokenWidget/index.tsx | 27 ++++++-- .../hooks/useAddTokenImportCallback.ts | 55 ++++++++++++++- .../hooks/useRestrictedTokenImportStatus.ts | 69 +++++++++++++++++++ .../pure/ImportTokenModal/index.tsx | 12 +++- .../src/hooks/tokens/useRestrictedToken.ts | 2 +- libs/tokens/src/index.ts | 3 +- 11 files changed, 185 insertions(+), 29 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts diff --git a/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx b/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx index 26600b140a1..82a582f30eb 100644 --- a/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx @@ -2,13 +2,9 @@ import { ReactNode, useCallback, useMemo } from 'react' import { useWalletInfo } from '@cowprotocol/wallet' +import { RwaConsentKey, RwaConsentModal, useRwaConsentModalState, useRwaConsentStatus } from 'modules/rwa' import { useTradeConfirmActions } from 'modules/trade' -import { useRwaConsentModalState } from '../../hooks/useRwaConsentModalState' -import { useRwaConsentStatus } from '../../hooks/useRwaConsentStatus' -import { RwaConsentModal } from '../../pure/RwaConsentModal' -import { RwaConsentKey } from '../../types/rwaConsent' - export function RwaConsentModalContainer(): ReactNode { const { account } = useWalletInfo() const { isModalOpen, closeModal, context } = useRwaConsentModalState() @@ -37,12 +33,26 @@ export function RwaConsentModalContainer(): ReactNode { confirmConsent() closeModal() - tradeConfirmActions.onOpen() + + // if this is a token import flow, call the success callback to proceed to import modal + // if this is a trade flow, open the trade confirmation + if (context.onImportSuccess) { + context.onImportSuccess() + } else { + tradeConfirmActions.onOpen() + } }, [account, context, consentKey, confirmConsent, closeModal, tradeConfirmActions]) if (!isModalOpen || !context) { return null } - return + return ( + + ) } diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts index 592755a3e0c..5785a5da993 100644 --- a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts +++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts @@ -1,18 +1,14 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useCallback } from 'react' -import { TokenWithLogo } from '@cowprotocol/common-const' - import { rwaConsentModalStateAtom, updateRwaConsentModalStateAtom, RwaConsentModalState, + RwaConsentModalContext, } from '../state/rwaConsentModalStateAtom' -export interface RwaConsentModalContext { - consentHash: string - token?: TokenWithLogo -} +export type { RwaConsentModalContext } export function useRwaConsentModalState(): { isModalOpen: boolean diff --git a/apps/cowswap-frontend/src/modules/rwa/index.ts b/apps/cowswap-frontend/src/modules/rwa/index.ts index 1a34c451843..61614e1adf1 100644 --- a/apps/cowswap-frontend/src/modules/rwa/index.ts +++ b/apps/cowswap-frontend/src/modules/rwa/index.ts @@ -5,6 +5,7 @@ export * from './state/geoDataAtom' export * from './hooks/useRwaConsentStatus' export * from './hooks/useRwaConsentModalState' export * from './hooks/useGeoCountry' +export * from './hooks/useGeoStatus' export * from './hooks/useRwaTokenStatus' export * from './pure/RwaConsentModal' export * from './containers/RwaConsentModalContainer' diff --git a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx index 7099f509f8e..13417f0b1c0 100644 --- a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx @@ -10,6 +10,7 @@ export interface RwaConsentModalProps { onDismiss(): void onConfirm(): void token?: TokenWithLogo + consentHash?: string } export function RwaConsentModal(props: RwaConsentModalProps): ReactNode { diff --git a/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts b/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts index 56e96228285..52b211142b1 100644 --- a/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts +++ b/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts @@ -3,12 +3,16 @@ import { atom } from 'jotai' import { TokenWithLogo } from '@cowprotocol/common-const' import { atomWithPartialUpdate } from '@cowprotocol/common-utils' +export interface RwaConsentModalContext { + consentHash: string + token?: TokenWithLogo + pendingImportTokens?: TokenWithLogo[] + onImportSuccess?: () => void +} + export interface RwaConsentModalState { isModalOpen: boolean - context?: { - consentHash: string - token?: TokenWithLogo - } + context?: RwaConsentModalContext } const initialRwaConsentModalState: RwaConsentModalState = { diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 8f09782ae7e..a7b016d2ea7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -31,6 +31,7 @@ import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' import { useOnSelectChain } from '../../hooks/useOnSelectChain' import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useRestrictedTokenImportStatus } from '../../hooks/useRestrictedTokenImportStatus' import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' import { useTokensToSelect } from '../../hooks/useTokensToSelect' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' @@ -118,6 +119,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok const closeTokenSelectWidget = useCloseTokenSelectWidget() + const { isImportDisabled, blockReason } = useRestrictedTokenImportStatus(tokenToImport) + const openPoolPage = useCallback( (selectedPoolAddress: string) => { updateSelectTokenWidget({ selectedPoolAddress }) @@ -140,11 +143,23 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok closeTokenSelectWidget() }, [closeTokenSelectWidget]) - const importTokenAndClose = (tokens: TokenWithLogo[]): void => { - importTokenCallback(tokens) - onSelectToken?.(tokens[0]) - onDismiss() - } + const selectAndClose = useCallback( + (token: TokenWithLogo): void => { + onSelectToken?.(token) + onDismiss() + }, + [onSelectToken, onDismiss], + ) + + const importTokenAndClose = useCallback( + (tokens: TokenWithLogo[]): void => { + importTokenCallback(tokens) + if (tokens[0]) { + selectAndClose(tokens[0]) + } + }, + [importTokenCallback, selectAndClose], + ) const importListAndBack = (list: ListState): void => { try { @@ -168,6 +183,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok onDismiss={onDismiss} onBack={resetTokenImport} onImport={importTokenAndClose} + isImportDisabled={isImportDisabled} + blockReason={blockReason} /> ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts index 4ee2efb41bf..9aaba77d528 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts @@ -1,18 +1,67 @@ +import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' +import { findRestrictedToken, restrictedTokensAtom } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { + getConsentFromCache, + rwaConsentCacheAtom, + RwaConsentKey, + useGeoStatus, + useRwaConsentModalState, +} from 'modules/rwa' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' export function useAddTokenImportCallback(): (tokenToImport: TokenWithLogo) => void { + const { account } = useWalletInfo() const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const { openModal: openRwaConsentModal } = useRwaConsentModalState() + const restrictedList = useAtomValue(restrictedTokensAtom) + const consentCache = useAtomValue(rwaConsentCacheAtom) + const geoStatus = useGeoStatus() return useCallback( (tokenToImport: TokenWithLogo) => { - updateSelectTokenWidget({ - tokenToImport, + if (!restrictedList.isLoaded || geoStatus.isLoading) { + updateSelectTokenWidget({ tokenToImport }) + return + } + + const restrictedInfo = findRestrictedToken(tokenToImport, restrictedList) + + if (!restrictedInfo) { + updateSelectTokenWidget({ tokenToImport }) + return + } + + // if country is known, allow import (blocked check happens in import modal) + if (geoStatus.country) { + updateSelectTokenWidget({ tokenToImport }) + return + } + + // country unknown- need consent before import + if (account) { + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: restrictedInfo.consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + if (existingConsent?.acceptedAt) { + updateSelectTokenWidget({ tokenToImport }) + return + } + } + + openRwaConsentModal({ + consentHash: restrictedInfo.consentHash, + token: tokenToImport, + pendingImportTokens: [tokenToImport], + onImportSuccess: () => { + updateSelectTokenWidget({ tokenToImport }) + }, }) }, - [updateSelectTokenWidget], + [account, updateSelectTokenWidget, openRwaConsentModal, restrictedList, consentCache, geoStatus], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts new file mode 100644 index 00000000000..848fb0413da --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts @@ -0,0 +1,69 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { findRestrictedToken, restrictedTokensAtom, RestrictedTokenInfo } from '@cowprotocol/tokens' + +import { useGeoStatus } from 'modules/rwa' + +export enum RestrictedTokenImportStatus { + NotRestricted = 'NotRestricted', + Blocked = 'Blocked', +} + +export interface RestrictedTokenImportResult { + status: RestrictedTokenImportStatus + restrictedInfo: RestrictedTokenInfo | null + isImportDisabled: boolean + blockReason: string | null +} + +export function useRestrictedTokenImportStatus(token: TokenWithLogo | undefined): RestrictedTokenImportResult { + const geoStatus = useGeoStatus() + const restrictedList = useAtomValue(restrictedTokensAtom) + + return useMemo(() => { + // if geo or restricted list is loading, allow import (will be checked at trade time) + if (!token || !restrictedList.isLoaded || geoStatus.isLoading) { + return { + status: RestrictedTokenImportStatus.NotRestricted, + restrictedInfo: null, + isImportDisabled: false, + blockReason: null, + } + } + + const restrictedInfo = findRestrictedToken(token, restrictedList) + + if (!restrictedInfo) { + return { + status: RestrictedTokenImportStatus.NotRestricted, + restrictedInfo: null, + isImportDisabled: false, + blockReason: null, + } + } + + // Only block import if country is known and blocked + if (geoStatus.country) { + const countryUpper = geoStatus.country.toUpperCase() + const blockedCountries = new Set(restrictedInfo.restrictedCountries) + + if (blockedCountries.has(countryUpper)) { + return { + status: RestrictedTokenImportStatus.Blocked, + restrictedInfo, + isImportDisabled: true, + blockReason: 'This token is not available in your region.', + } + } + } + + return { + status: RestrictedTokenImportStatus.NotRestricted, + restrictedInfo: null, + isImportDisabled: false, + blockReason: null, + } + }, [token, restrictedList, geoStatus]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx index 8f5c8b19408..87ac9e40fc7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx @@ -16,6 +16,8 @@ const ExternalLinkStyled = styled(ExternalLink)` export interface ImportTokenModalProps { tokens: TokenWithLogo[] + isImportDisabled?: boolean + blockReason?: string | null onBack?(): void @@ -27,7 +29,7 @@ export interface ImportTokenModalProps { // TODO: Add proper return type annotation // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function ImportTokenModal(props: ImportTokenModalProps) { - const { tokens, onBack, onDismiss, onImport } = props + const { tokens, onBack, onDismiss, onImport, isImportDisabled, blockReason } = props return ( @@ -61,7 +63,13 @@ export function ImportTokenModal(props: ImportTokenModalProps) { ))} - onImport(tokens)}> + {blockReason && ( + + + {blockReason} + + )} + onImport(tokens)} disabled={isImportDisabled}> Import diff --git a/libs/tokens/src/hooks/tokens/useRestrictedToken.ts b/libs/tokens/src/hooks/tokens/useRestrictedToken.ts index e59801e4b3d..400468a61be 100644 --- a/libs/tokens/src/hooks/tokens/useRestrictedToken.ts +++ b/libs/tokens/src/hooks/tokens/useRestrictedToken.ts @@ -16,7 +16,7 @@ export interface RestrictedTokenInfo { consentHash: string } -function findRestrictedToken( +export function findRestrictedToken( token: Token | undefined, restrictedList: RestrictedTokenListState, ): RestrictedTokenInfo | undefined { diff --git a/libs/tokens/src/index.ts b/libs/tokens/src/index.ts index 03fcd8574c2..9431d3670c0 100644 --- a/libs/tokens/src/index.ts +++ b/libs/tokens/src/index.ts @@ -56,7 +56,8 @@ export { useSearchList } from './hooks/lists/useSearchList' export { useSearchToken } from './hooks/tokens/useSearchToken' export { useSearchNonExistentToken } from './hooks/tokens/useSearchNonExistentToken' export { useAllLpTokens } from './hooks/tokens/useAllLpTokens' -export { useRestrictedToken, useAnyRestrictedToken } from './hooks/tokens/useRestrictedToken' +export { useRestrictedToken, useAnyRestrictedToken, findRestrictedToken } from './hooks/tokens/useRestrictedToken' +export { restrictedTokensAtom } from './state/restrictedTokens/restrictedTokensAtom' // Utils export { getTokenId } from './state/restrictedTokens/restrictedTokensAtom' From 22720a103dbf954d8a7f065834cc2ee78f3f3172 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Fri, 26 Dec 2025 00:28:36 +0400 Subject: [PATCH 07/25] feat: add i18n for block reason --- .../tokensList/hooks/useRestrictedTokenImportStatus.ts | 4 +++- .../modules/tokensList/pure/ImportTokenModal/index.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts index 848fb0413da..9c5f09ea16b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts @@ -4,6 +4,8 @@ import { useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { findRestrictedToken, restrictedTokensAtom, RestrictedTokenInfo } from '@cowprotocol/tokens' +import { t } from '@lingui/core/macro' + import { useGeoStatus } from 'modules/rwa' export enum RestrictedTokenImportStatus { @@ -54,7 +56,7 @@ export function useRestrictedTokenImportStatus(token: TokenWithLogo | undefined) status: RestrictedTokenImportStatus.Blocked, restrictedInfo, isImportDisabled: true, - blockReason: 'This token is not available in your region.', + blockReason: t`This token is not available in your region.`, } } } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx index 87ac9e40fc7..4c9b0f1e68e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx @@ -1,3 +1,5 @@ +import { ReactElement } from 'react' + import { TokenWithLogo } from '@cowprotocol/common-const' import { ExplorerDataType, getExplorerLink } from '@cowprotocol/common-utils' import { TokenLogo } from '@cowprotocol/tokens' @@ -26,9 +28,7 @@ export interface ImportTokenModalProps { onImport(tokens: TokenWithLogo[]): void } -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function ImportTokenModal(props: ImportTokenModalProps) { +export function ImportTokenModal(props: ImportTokenModalProps): ReactElement { const { tokens, onBack, onDismiss, onImport, isImportDisabled, blockReason } = props return ( @@ -64,10 +64,10 @@ export function ImportTokenModal(props: ImportTokenModalProps) { ))} {blockReason && ( - + {blockReason} - + )} onImport(tokens)} disabled={isImportDisabled}> Import From 1785f97d6c4c80676020e163738baf2b7b88e422 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Fri, 26 Dec 2025 00:35:56 +0400 Subject: [PATCH 08/25] refactor: reuse a hook --- .../hooks/useRestrictedTokenImportStatus.ts | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts index 9c5f09ea16b..19e9dee0747 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts @@ -1,8 +1,7 @@ -import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' -import { findRestrictedToken, restrictedTokensAtom, RestrictedTokenInfo } from '@cowprotocol/tokens' +import { RestrictedTokenInfo, useRestrictedToken } from '@cowprotocol/tokens' import { t } from '@lingui/core/macro' @@ -20,33 +19,24 @@ export interface RestrictedTokenImportResult { blockReason: string | null } +const NOT_RESTRICTED_RESULT: RestrictedTokenImportResult = { + status: RestrictedTokenImportStatus.NotRestricted, + restrictedInfo: null, + isImportDisabled: false, + blockReason: null, +} + export function useRestrictedTokenImportStatus(token: TokenWithLogo | undefined): RestrictedTokenImportResult { const geoStatus = useGeoStatus() - const restrictedList = useAtomValue(restrictedTokensAtom) + const restrictedInfo = useRestrictedToken(token) return useMemo(() => { - // if geo or restricted list is loading, allow import (will be checked at trade time) - if (!token || !restrictedList.isLoaded || geoStatus.isLoading) { - return { - status: RestrictedTokenImportStatus.NotRestricted, - restrictedInfo: null, - isImportDisabled: false, - blockReason: null, - } - } - - const restrictedInfo = findRestrictedToken(token, restrictedList) - - if (!restrictedInfo) { - return { - status: RestrictedTokenImportStatus.NotRestricted, - restrictedInfo: null, - isImportDisabled: false, - blockReason: null, - } + // if geo is loading or token is not restricted, allow import + if (geoStatus.isLoading || !restrictedInfo) { + return NOT_RESTRICTED_RESULT } - // Only block import if country is known and blocked + // only block import if country is known and blocked if (geoStatus.country) { const countryUpper = geoStatus.country.toUpperCase() const blockedCountries = new Set(restrictedInfo.restrictedCountries) @@ -61,11 +51,6 @@ export function useRestrictedTokenImportStatus(token: TokenWithLogo | undefined) } } - return { - status: RestrictedTokenImportStatus.NotRestricted, - restrictedInfo: null, - isImportDisabled: false, - blockReason: null, - } - }, [token, restrictedList, geoStatus]) + return NOT_RESTRICTED_RESULT + }, [geoStatus, restrictedInfo]) } From e5e2c2d9fa931880c63e4c3676926f6d5514502e Mon Sep 17 00:00:00 2001 From: limitofzero Date: Fri, 26 Dec 2025 01:02:35 +0400 Subject: [PATCH 09/25] feat: don't allow to user switch on restricted token list for blocked country --- .../containers/ManageLists/index.tsx | 16 ++++++++-- .../src/hooks/lists/useFilterBlockedLists.ts | 30 +++++++++++++++++++ libs/tokens/src/index.ts | 1 + .../restrictedTokens/restrictedTokensAtom.ts | 16 ++++++++++ .../RestrictedTokensListUpdater/index.tsx | 19 ++++++++++-- 5 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 libs/tokens/src/hooks/lists/useFilterBlockedLists.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx index beadf7eb152..8914d973607 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx @@ -1,11 +1,20 @@ import { useMemo } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' -import { ListSearchResponse, ListState, useListsEnabledState, useRemoveList, useToggleList } from '@cowprotocol/tokens' +import { + ListSearchResponse, + ListState, + useFilterBlockedLists, + useListsEnabledState, + useRemoveList, + useToggleList, +} from '@cowprotocol/tokens' import { Loader } from '@cowprotocol/ui' import { Trans } from '@lingui/react/macro' +import { useGeoCountry } from 'modules/rwa' + import { CowSwapAnalyticsCategory, toCowSwapGtmEvent } from 'common/analytics/types' import * as styledEl from './styled' @@ -32,6 +41,9 @@ export interface ManageListsProps { export function ManageLists(props: ManageListsProps) { const { lists, listSearchResponse, isListUrlValid } = props + const country = useGeoCountry() + const filteredLists = useFilterBlockedLists(lists, country) + const activeTokenListsIds = useListsEnabledState() const addListImport = useAddListImport() const cowAnalytics = useCowAnalytics() @@ -85,7 +97,7 @@ export function ManageLists(props: ManageListsProps) { )} - {lists + {filteredLists .sort((a, b) => (a.priority || 0) - (b.priority || 0)) .map((list) => ( { + if (!country || !restrictedLists.isLoaded) { + return lists + } + + const countryUpper = country.toUpperCase() + + return lists.filter((list) => { + const blockedCountries = restrictedLists.blockedCountriesPerList[list.source] + + if (!blockedCountries) { + return true + } + + return !blockedCountries.includes(countryUpper) + }) + }, [lists, country, restrictedLists]) +} diff --git a/libs/tokens/src/index.ts b/libs/tokens/src/index.ts index 9431d3670c0..d92832ebbd8 100644 --- a/libs/tokens/src/index.ts +++ b/libs/tokens/src/index.ts @@ -73,3 +73,4 @@ export { fetchTokenList } from './services/fetchTokenList' // Consts export { DEFAULT_TOKENS_LISTS } from './const/tokensLists' export { useIsAnyOfTokensOndo } from './hooks/lists/useIsAnyOfTokensOndo' +export { useFilterBlockedLists } from './hooks/lists/useFilterBlockedLists' diff --git a/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts b/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts index be4166f64a5..9d711c80ceb 100644 --- a/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts +++ b/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts @@ -42,3 +42,19 @@ export const restrictedTokensLastUpdateAtom = atomWithStorage( getJotaiMergerStorage(), { unstable_getOnInit: true }, ) + +/** + * maps token list source URLs to their blocked countries + * used to hide entire token lists for users in blocked countries + */ +export interface RestrictedListsState { + blockedCountriesPerList: Record + isLoaded: boolean +} + +const initialRestrictedListsState: RestrictedListsState = { + blockedCountriesPerList: {}, + isLoaded: false, +} + +export const restrictedListsAtom = atom(initialRestrictedListsState) diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx index 37e46b17758..dc50fdfe427 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -1,10 +1,16 @@ +import { useSetAtom } from 'jotai' import { useEffect } from 'react' import { getRestrictedTokenLists } from '@cowprotocol/core' import { TokenInfo } from '@cowprotocol/types' import { useRestrictedTokensCache } from '../../hooks/useRestrictedTokensCache' -import { getTokenId, RestrictedTokenListState, TokenId } from '../../state/restrictedTokens/restrictedTokensAtom' +import { + getTokenId, + restrictedListsAtom, + RestrictedTokenListState, + TokenId, +} from '../../state/restrictedTokens/restrictedTokensAtom' const FETCH_TIMEOUT_MS = 10_000 const MAX_RETRIES = 1 @@ -75,6 +81,7 @@ async function fetchTokenList(url: string, retries = MAX_RETRIES): Promise { if (!isRwaGeoblockEnabled) { @@ -92,9 +99,12 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted const tokensMap: Record = {} const countriesPerToken: Record = {} const consentHashPerToken: Record = {} + const blockedCountriesPerList: Record = {} await Promise.all( restrictedLists.map(async (list) => { + blockedCountriesPerList[list.tokenListUrl] = list.restrictedCountries + try { const tokens = await fetchTokenList(list.tokenListUrl) @@ -117,6 +127,11 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted isLoaded: true, } + setRestrictedLists({ + blockedCountriesPerList, + isLoaded: true, + }) + saveToCache(listState) } catch (error) { console.error('Error loading restricted tokens:', error) @@ -124,7 +139,7 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted } loadRestrictedTokens() - }, [isRwaGeoblockEnabled, shouldFetch, saveToCache]) + }, [isRwaGeoblockEnabled, shouldFetch, saveToCache, setRestrictedLists]) return null } From f8d0e919d58b80f2a4b699a898a51cb08c93b419 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Fri, 26 Dec 2025 16:06:53 +0400 Subject: [PATCH 10/25] feat: add block to import restricted list --- .../containers/ManageLists/index.tsx | 4 ++ .../containers/SelectTokenWidget/index.tsx | 11 ++++ .../tokensList/pure/ImportListModal/index.tsx | 53 +++++++++++-------- .../tokensList/pure/ImportListModal/styled.ts | 13 +++++ .../pure/ImportTokenListItem/index.tsx | 12 ++++- .../pure/ImportTokenListItem/styled.ts | 11 ++++ .../src/hooks/lists/useFilterBlockedLists.ts | 4 +- .../src/hooks/lists/useIsListBlocked.ts | 40 ++++++++++++++ libs/tokens/src/index.ts | 1 + .../RestrictedTokensListUpdater/index.tsx | 11 ++-- 10 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 libs/tokens/src/hooks/lists/useIsListBlocked.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx index 8914d973607..9a1b064b5d1 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx @@ -5,6 +5,7 @@ import { ListSearchResponse, ListState, useFilterBlockedLists, + useIsListBlocked, useListsEnabledState, useRemoveList, useToggleList, @@ -65,6 +66,8 @@ export function ManageLists(props: ManageListsProps) { }) const { source, listToImport, loading } = useListSearchResponse(listSearchResponse) + const { isBlocked: isListToImportBlocked } = useIsListBlocked(listToImport?.source, country) + console.log('[ManageLists] isBlocked:', isListToImportBlocked) return ( @@ -83,6 +86,7 @@ export function ManageLists(props: ManageListsProps) { { @@ -193,6 +203,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok return ( - setIsAccepted((state) => !state)} - > - -

    - By adding this list you are implicitly trusting that the data is correct. Anyone can create a list, - including creating fake versions of existing lists and lists that claim to represent projects that do not - have one. -

    -

    - If you purchase a token from this list, you may not be able to sell it back. -

    -
    -
    - - onImport(list)}> - Import - - + {isBlocked ? ( + + + This token list is not available in your region. + + ) : ( + <> + setIsAccepted((state) => !state)} + > + +

    + By adding this list you are implicitly trusting that the data is correct. Anyone can create a list, + including creating fake versions of existing lists and lists that claim to represent projects that do + not have one. +

    +

    + If you purchase a token from this list, you may not be able to sell it back. +

    +
    +
    + + onImport(list)}> + Import + + + + )}
    ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/styled.ts index 23200a3ee39..22552f9b66e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/styled.ts @@ -44,3 +44,16 @@ export const ListLink = styled.a` export const ExternalSourceAlertStyled = styled(ExternalSourceAlert)` margin: 0 20px 20px 20px; ` + +export const BlockedWarning = styled.div` + display: flex; + gap: 10px; + align-items: center; + justify-content: center; + background: var(${UI.COLOR_DANGER_BG}); + color: var(${UI.COLOR_DANGER_TEXT}); + border-radius: 10px; + font-size: 14px; + padding: 16px 20px; + margin: 0 20px 20px 20px; +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx index 5eb9837cd14..ee01b215502 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx @@ -1,7 +1,7 @@ import { ListState } from '@cowprotocol/tokens' import { Trans } from '@lingui/react/macro' -import { CheckCircle } from 'react-feather' +import { CheckCircle, AlertCircle } from 'react-feather' import * as styledEl from './styled' @@ -11,13 +11,14 @@ import { TokenListDetails } from '../TokenListDetails' export interface ImportTokenListItemProps { list: ListState source: 'existing' | 'external' + isBlocked?: boolean importList(list: ListState): void } // TODO: Add proper return type annotation // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function ImportTokenListItem(props: ImportTokenListItemProps) { - const { list, source, importList } = props + const { list, source, importList, isBlocked } = props return ( @@ -29,6 +30,13 @@ export function ImportTokenListItem(props: ImportTokenListItemProps) { Loaded + ) : isBlocked ? ( + + + + Not available in your region + + ) : (
    importList(list)}> diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts index e4a364871b8..6cc95d0fa22 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts @@ -1,3 +1,5 @@ +import { UI } from '@cowprotocol/ui' + import styled from 'styled-components/macro' export const Wrapper = styled.div` @@ -14,3 +16,12 @@ export const LoadedInfo = styled.div` gap: 10px; align-items: center; ` + +export const BlockedInfo = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + color: var(${UI.COLOR_DANGER_TEXT}); + font-size: 13px; +` diff --git a/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts b/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts index 6b8d8dc1a30..16777302619 100644 --- a/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts +++ b/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts @@ -1,6 +1,8 @@ import { useAtomValue } from 'jotai' import { useMemo } from 'react' +import { normalizeListSource } from './useIsListBlocked' + import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' import { ListState } from '../../types' @@ -18,7 +20,7 @@ export function useFilterBlockedLists(lists: ListState[], country: string | null const countryUpper = country.toUpperCase() return lists.filter((list) => { - const blockedCountries = restrictedLists.blockedCountriesPerList[list.source] + const blockedCountries = restrictedLists.blockedCountriesPerList[normalizeListSource(list.source)] if (!blockedCountries) { return true diff --git a/libs/tokens/src/hooks/lists/useIsListBlocked.ts b/libs/tokens/src/hooks/lists/useIsListBlocked.ts new file mode 100644 index 00000000000..516c263bbae --- /dev/null +++ b/libs/tokens/src/hooks/lists/useIsListBlocked.ts @@ -0,0 +1,40 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' + +export function normalizeListSource(source: string): string { + return source.toLowerCase().trim() +} + +export interface ListBlockedResult { + isBlocked: boolean + isLoading: boolean +} + +/** + * check if a token list is blocked for the given country + */ +export function useIsListBlocked(listSource: string | undefined, country: string | null): ListBlockedResult { + const restrictedLists = useAtomValue(restrictedListsAtom) + + return useMemo(() => { + if (!listSource || !restrictedLists.isLoaded) { + return { isBlocked: false, isLoading: !restrictedLists.isLoaded } + } + + if (!country) { + return { isBlocked: false, isLoading: false } + } + + const blockedCountries = restrictedLists.blockedCountriesPerList[normalizeListSource(listSource)] + + if (!blockedCountries) { + return { isBlocked: false, isLoading: false } + } + + const countryUpper = country.toUpperCase() + const isBlocked = blockedCountries.includes(countryUpper) + return { isBlocked, isLoading: false } + }, [listSource, country, restrictedLists]) +} diff --git a/libs/tokens/src/index.ts b/libs/tokens/src/index.ts index d92832ebbd8..d5b6d3d2a53 100644 --- a/libs/tokens/src/index.ts +++ b/libs/tokens/src/index.ts @@ -74,3 +74,4 @@ export { fetchTokenList } from './services/fetchTokenList' export { DEFAULT_TOKENS_LISTS } from './const/tokensLists' export { useIsAnyOfTokensOndo } from './hooks/lists/useIsAnyOfTokensOndo' export { useFilterBlockedLists } from './hooks/lists/useFilterBlockedLists' +export { useIsListBlocked } from './hooks/lists/useIsListBlocked' diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx index dc50fdfe427..d0a159f11e0 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -1,9 +1,10 @@ -import { useSetAtom } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { useEffect } from 'react' import { getRestrictedTokenLists } from '@cowprotocol/core' import { TokenInfo } from '@cowprotocol/types' +import { normalizeListSource } from '../../hooks/lists/useIsListBlocked' import { useRestrictedTokensCache } from '../../hooks/useRestrictedTokensCache' import { getTokenId, @@ -80,8 +81,12 @@ async function fetchTokenList(url: string, retries = MAX_RETRIES): Promise { if (!isRwaGeoblockEnabled) { @@ -103,7 +108,7 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted await Promise.all( restrictedLists.map(async (list) => { - blockedCountriesPerList[list.tokenListUrl] = list.restrictedCountries + blockedCountriesPerList[normalizeListSource(list.tokenListUrl)] = list.restrictedCountries try { const tokens = await fetchTokenList(list.tokenListUrl) From 7d2a4974a658c41aa8ee4a5f84491fb2cc0200f9 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 02:20:22 +0400 Subject: [PATCH 11/25] feat: consent flow for token lists --- .../application/containers/App/Updaters.tsx | 3 +- .../containers/ManageLists/index.tsx | 83 +++++++++++++++++-- .../containers/SelectTokenWidget/index.tsx | 16 ++-- .../tokensList/hooks/useAddListImport.ts | 57 ++++++++++++- .../hooks/useFilterListsWithConsent.ts | 59 +++++++++++++ .../hooks/useIsListRequiresConsent.ts | 71 ++++++++++++++++ .../src/modules/tokensList/index.ts | 1 + .../tokensList/pure/ImportListModal/index.tsx | 6 +- .../pure/ImportTokenListItem/index.tsx | 9 +- .../updaters/BlockedListSourcesUpdater.tsx | 41 +++++++++ .../src/hooks/lists/useRestrictedListInfo.ts | 36 ++++++++ libs/tokens/src/index.ts | 10 ++- .../restrictedTokens/restrictedTokensAtom.ts | 4 +- libs/tokens/src/state/tokens/allTokensAtom.ts | 53 +++++++----- .../state/tokens/blockedListSourcesAtom.ts | 10 +++ .../RestrictedTokensListUpdater/index.tsx | 10 ++- 16 files changed, 415 insertions(+), 54 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx create mode 100644 libs/tokens/src/hooks/lists/useRestrictedListInfo.ts create mode 100644 libs/tokens/src/state/tokens/blockedListSourcesAtom.ts 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 88dd697c77a..b479a7c4e1e 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx @@ -31,7 +31,7 @@ import { ProgressBarExecutingOrdersUpdater, } from 'modules/orderProgressBar' import { OrdersNotificationsUpdater } from 'modules/orders' -import { useSourceChainId } from 'modules/tokensList' +import { BlockedListSourcesUpdater, useSourceChainId } from 'modules/tokensList' import { TradeType, useTradeTypeInfo } from 'modules/trade' import { UsdPricesUpdater } from 'modules/usdAmount' import { LpTokensWithBalancesUpdater, PoolsInfoUpdater, VampireAttackUpdater } from 'modules/yield/shared' @@ -115,6 +115,7 @@ export function Updaters(): ReactNode { bridgeNetworkInfo={bridgeNetworkInfo?.data} /> + diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx index 9a1b064b5d1..07f1f58c55d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx @@ -1,9 +1,12 @@ -import { useMemo } from 'react' +import { useAtomValue } from 'jotai' +import { ReactNode, useCallback, useMemo } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' import { ListSearchResponse, ListState, + normalizeListSource, + restrictedListsAtom, useFilterBlockedLists, useIsListBlocked, useListsEnabledState, @@ -11,16 +14,25 @@ import { useToggleList, } from '@cowprotocol/tokens' import { Loader } from '@cowprotocol/ui' +import { useWalletInfo } from '@cowprotocol/wallet' import { Trans } from '@lingui/react/macro' -import { useGeoCountry } from 'modules/rwa' +import { + getConsentFromCache, + rwaConsentCacheAtom, + RwaConsentKey, + useGeoCountry, + useGeoStatus, + useRwaConsentModalState, +} from 'modules/rwa' import { CowSwapAnalyticsCategory, toCowSwapGtmEvent } from 'common/analytics/types' import * as styledEl from './styled' import { useAddListImport } from '../../hooks/useAddListImport' +import { useIsListRequiresConsent } from '../../hooks/useIsListRequiresConsent' import { ImportTokenListItem } from '../../pure/ImportTokenListItem' import { ListItem } from '../../pure/ListItem' @@ -37,12 +49,19 @@ export interface ManageListsProps { } // TODO: Break down this large function into smaller functions -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function ManageLists(props: ManageListsProps) { +// eslint-disable-next-line max-lines-per-function +export function ManageLists(props: ManageListsProps): ReactNode { const { lists, listSearchResponse, isListUrlValid } = props + const { account } = useWalletInfo() const country = useGeoCountry() + const geoStatus = useGeoStatus() + const restrictedLists = useAtomValue(restrictedListsAtom) + const consentCache = useAtomValue(rwaConsentCacheAtom) + const { openModal: openRwaConsentModal } = useRwaConsentModalState() + + // Only filter by country (blocked), NOT by consent requirement + // Lists requiring consent should be visible so users can give consent const filteredLists = useFilterBlockedLists(lists, country) const activeTokenListsIds = useListsEnabledState() @@ -57,7 +76,7 @@ export function ManageLists(props: ManageListsProps) { }) }) - const toggleList = useToggleList((enable, source) => { + const baseToggleList = useToggleList((enable, source) => { cowAnalytics.sendEvent({ category: CowSwapAnalyticsCategory.LIST, action: `${enable ? 'Enable' : 'Disable'} List`, @@ -65,9 +84,56 @@ export function ManageLists(props: ManageListsProps) { }) }) + // Wrapper that checks if consent is required before toggling + const toggleList = useCallback( + (list: ListState, enabled: boolean) => { + // Only check consent when trying to enable (not disable) + if (enabled) { + // Already enabled, just toggle off + baseToggleList(list, enabled) + return + } + + // Trying to enable - check if consent is required + if (!geoStatus.country && restrictedLists.isLoaded) { + const normalizedSource = normalizeListSource(list.source) + const consentHash = restrictedLists.consentHashPerList[normalizedSource] + + if (consentHash) { + // List is restricted - check if consent exists + let hasConsent = false + if (account) { + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + hasConsent = !!existingConsent?.acceptedAt + } + + if (!hasConsent) { + // Need consent - open modal + openRwaConsentModal({ + consentHash, + onImportSuccess: () => { + // After consent, toggle the list on + baseToggleList(list, enabled) + }, + }) + return + } + } + } + + // No consent required or consent already given + baseToggleList(list, enabled) + }, + [baseToggleList, geoStatus.country, restrictedLists, account, consentCache, openRwaConsentModal], + ) + const { source, listToImport, loading } = useListSearchResponse(listSearchResponse) const { isBlocked: isListToImportBlocked } = useIsListBlocked(listToImport?.source, country) - console.log('[ManageLists] isBlocked:', isListToImportBlocked) + const { requiresConsent } = useIsListRequiresConsent(listToImport?.source) + + // Block the list if country is blocked OR if consent is required (unknown country, no consent) + const isBlocked = isListToImportBlocked || requiresConsent return ( @@ -86,7 +152,8 @@ export function ManageLists(props: ManageListsProps) { { @@ -200,10 +199,15 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok } if (listToImport && !standalone) { + const listBlockReason = listRequiresConsent + ? 'This list requires consent before importing. Please connect your wallet.' + : undefined + return ( void { + const { account } = useWalletInfo() const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const { openModal: openRwaConsentModal } = useRwaConsentModalState() + const restrictedLists = useAtomValue(restrictedListsAtom) + const consentCache = useAtomValue(rwaConsentCacheAtom) + const geoStatus = useGeoStatus() return useCallback( (listToImport: ListState) => { - updateSelectTokenWidget({ - listToImport, + // If restricted lists not loaded or geo is loading, just proceed + if (!restrictedLists.isLoaded || geoStatus.isLoading) { + updateSelectTokenWidget({ listToImport }) + return + } + + const normalizedSource = normalizeListSource(listToImport.source) + const consentHash = restrictedLists.consentHashPerList[normalizedSource] + + // If list is not in restricted lists, proceed normally + if (!consentHash) { + updateSelectTokenWidget({ listToImport }) + return + } + + // If country is known, allow import (blocked check happens in import modal) + if (geoStatus.country) { + updateSelectTokenWidget({ listToImport }) + return + } + + // Country unknown - need consent before import + if (account) { + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + if (existingConsent?.acceptedAt) { + updateSelectTokenWidget({ listToImport }) + return + } + } + + openRwaConsentModal({ + consentHash, + onImportSuccess: () => { + updateSelectTokenWidget({ listToImport }) + }, }) }, - [updateSelectTokenWidget], + [account, updateSelectTokenWidget, openRwaConsentModal, restrictedLists, consentCache, geoStatus], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts new file mode 100644 index 00000000000..b108ca8b349 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts @@ -0,0 +1,59 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { ListState, normalizeListSource, restrictedListsAtom, useFilterBlockedLists } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' + +/** + * filters token lists that should not be visible: + * 1. lists blocked for the users country (when country is known) + * 2. restricted lists when country is unknown and consent is not given + */ +export function useFilterListsWithConsent(lists: ListState[]): ListState[] { + const { account } = useWalletInfo() + const geoStatus = useGeoStatus() + const restrictedLists = useAtomValue(restrictedListsAtom) + const consentCache = useAtomValue(rwaConsentCacheAtom) + + // First, filter by country if known + const countryFilteredLists = useFilterBlockedLists(lists, geoStatus.country) + + return useMemo(() => { + // If country is known, just return country-filtered lists + if (geoStatus.country) { + return countryFilteredLists + } + + // If geo is still loading, return all lists for now + if (geoStatus.isLoading) { + return countryFilteredLists + } + + // if restricted lists not loaded, return all + if (!restrictedLists.isLoaded) { + return countryFilteredLists + } + + return countryFilteredLists.filter((list) => { + const normalizedSource = normalizeListSource(list.source) + const consentHash = restrictedLists.consentHashPerList[normalizedSource] + + if (!consentHash) { + return true + } + + if (!account) { + // no wallet connected - hide restricted lists + return false + } + + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + + // Only show if consent is given + return !!existingConsent?.acceptedAt + }) + }, [countryFilteredLists, geoStatus, restrictedLists, account, consentCache]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts new file mode 100644 index 00000000000..4c8cd4b1e42 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts @@ -0,0 +1,71 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { normalizeListSource, restrictedListsAtom } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' + +export interface ListConsentResult { + /** True if list is restricted and country is unknown and no consent given */ + requiresConsent: boolean + /** The consent hash for this list (if restricted) */ + consentHash: string | null + /** True if we're still loading geo/restricted data */ + isLoading: boolean +} + +/** + * Checks if a list requires consent before it can be shown/imported. + * This is true when: + * 1. The list is in the restricted lists + * 2. The user's country is unknown + * 3. The user has not given consent yet + */ +export function useIsListRequiresConsent(listSource: string | undefined): ListConsentResult { + const { account } = useWalletInfo() + const geoStatus = useGeoStatus() + const restrictedLists = useAtomValue(restrictedListsAtom) + const consentCache = useAtomValue(rwaConsentCacheAtom) + + return useMemo(() => { + // If no source, no consent required + if (!listSource) { + return { requiresConsent: false, consentHash: null, isLoading: false } + } + + // If still loading, return loading state + if (!restrictedLists.isLoaded || geoStatus.isLoading) { + return { requiresConsent: false, consentHash: null, isLoading: true } + } + + const normalizedSource = normalizeListSource(listSource) + const consentHash = restrictedLists.consentHashPerList[normalizedSource] + + // If list is not restricted, no consent required + if (!consentHash) { + return { requiresConsent: false, consentHash: null, isLoading: false } + } + + // If country is known, no consent check needed (blocked check happens elsewhere) + if (geoStatus.country) { + return { requiresConsent: false, consentHash, isLoading: false } + } + + // Country is unknown - check if consent is given + if (!account) { + // No wallet connected - consent required + return { requiresConsent: true, consentHash, isLoading: false } + } + + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + + // Consent required if not already given + return { + requiresConsent: !existingConsent?.acceptedAt, + consentHash, + isLoading: false, + } + }, [listSource, restrictedLists, geoStatus, account, consentCache]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/index.ts b/apps/cowswap-frontend/src/modules/tokensList/index.ts index c38c9b46b97..4483317520d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/index.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts @@ -1,4 +1,5 @@ export { SelectTokenWidget } from './containers/SelectTokenWidget' +export { BlockedListSourcesUpdater } from './updaters/BlockedListSourcesUpdater' export { ImportTokenModal } from './pure/ImportTokenModal' export { AddIntermediateToken } from './pure/AddIntermediateToken' diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx index 4347912eea2..3e03e85ee0d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx @@ -12,6 +12,7 @@ import * as styledEl from './styled' export interface ImportListModalProps { list: ListState isBlocked?: boolean + blockReason?: string onImport(list: ListState): void @@ -21,7 +22,8 @@ export interface ImportListModalProps { } export function ImportListModal(props: ImportListModalProps): ReactNode { - const { list, onBack, onDismiss, onImport, isBlocked } = props + const { list, onBack, onDismiss, onImport, isBlocked, blockReason } = props + const defaultBlockReason = t`This token list is not available in your region.` const [isAccepted, setIsAccepted] = useState(false) @@ -46,7 +48,7 @@ export function ImportListModal(props: ImportListModalProps): ReactNode { {isBlocked ? ( - This token list is not available in your region. + {blockReason || defaultBlockReason} ) : ( <> diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx index ee01b215502..6c301ff1e3e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx @@ -12,18 +12,19 @@ export interface ImportTokenListItemProps { list: ListState source: 'existing' | 'external' isBlocked?: boolean + blockReason?: string importList(list: ListState): void } // TODO: Add proper return type annotation // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function ImportTokenListItem(props: ImportTokenListItemProps) { - const { list, source, importList, isBlocked } = props + const { list, source, importList, isBlocked, blockReason } = props return ( - {source === 'existing' ? ( + {source === 'existing' && !isBlocked ? ( @@ -33,9 +34,7 @@ export function ImportTokenListItem(props: ImportTokenListItemProps) { ) : isBlocked ? ( - - Not available in your region - + {blockReason || Not available in your region} ) : (
    diff --git a/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx new file mode 100644 index 00000000000..bdd47bba2c2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx @@ -0,0 +1,41 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' + +import { blockedListSourcesAtom, restrictedListsAtom } from '@cowprotocol/tokens' + +import { useGeoStatus } from 'modules/rwa' + +/** + * Updates 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) + */ +export function BlockedListSourcesUpdater(): null { + const geoStatus = useGeoStatus() + const restrictedLists = useAtomValue(restrictedListsAtom) + const setBlockedListSources = useSetAtom(blockedListSourcesAtom) + + useEffect(() => { + if (!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 countryUpper = geoStatus.country.toUpperCase() + + for (const [normalizedSource, blockedCountries] of Object.entries(restrictedLists.blockedCountriesPerList)) { + if (blockedCountries.includes(countryUpper)) { + blockedSources.add(normalizedSource) + } + } + } + + setBlockedListSources(blockedSources) + }, [geoStatus, restrictedLists, setBlockedListSources]) + + return null +} diff --git a/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts b/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts new file mode 100644 index 00000000000..37c75d7114f --- /dev/null +++ b/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts @@ -0,0 +1,36 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { normalizeListSource } from './useIsListBlocked' + +import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' + +export interface RestrictedListInfo { + source: string + blockedCountries: string[] + consentHash: string +} + +export function useRestrictedListInfo(listSource: string | undefined): RestrictedListInfo | null { + const restrictedLists = useAtomValue(restrictedListsAtom) + + return useMemo(() => { + if (!listSource || !restrictedLists.isLoaded) { + return null + } + + const normalizedSource = normalizeListSource(listSource) + const blockedCountries = restrictedLists.blockedCountriesPerList[normalizedSource] + const consentHash = restrictedLists.consentHashPerList[normalizedSource] + + if (!blockedCountries || !consentHash) { + return null + } + + return { + source: normalizedSource, + blockedCountries, + consentHash, + } + }, [listSource, restrictedLists]) +} diff --git a/libs/tokens/src/index.ts b/libs/tokens/src/index.ts index d5b6d3d2a53..953c369faed 100644 --- a/libs/tokens/src/index.ts +++ b/libs/tokens/src/index.ts @@ -57,7 +57,8 @@ export { useSearchToken } from './hooks/tokens/useSearchToken' export { useSearchNonExistentToken } from './hooks/tokens/useSearchNonExistentToken' export { useAllLpTokens } from './hooks/tokens/useAllLpTokens' export { useRestrictedToken, useAnyRestrictedToken, findRestrictedToken } from './hooks/tokens/useRestrictedToken' -export { restrictedTokensAtom } from './state/restrictedTokens/restrictedTokensAtom' +export { restrictedTokensAtom, restrictedListsAtom } from './state/restrictedTokens/restrictedTokensAtom' +export { blockedListSourcesAtom } from './state/tokens/blockedListSourcesAtom' // Utils export { getTokenId } from './state/restrictedTokens/restrictedTokensAtom' @@ -72,6 +73,11 @@ export { fetchTokenList } from './services/fetchTokenList' // Consts export { DEFAULT_TOKENS_LISTS } from './const/tokensLists' +export { RWA_CONSENT_HASH } from './updaters/RestrictedTokensListUpdater' export { useIsAnyOfTokensOndo } from './hooks/lists/useIsAnyOfTokensOndo' export { useFilterBlockedLists } from './hooks/lists/useFilterBlockedLists' -export { useIsListBlocked } from './hooks/lists/useIsListBlocked' +export { useIsListBlocked, normalizeListSource } from './hooks/lists/useIsListBlocked' +export { useRestrictedListInfo } from './hooks/lists/useRestrictedListInfo' + +// Types +export type { RestrictedListInfo } from './hooks/lists/useRestrictedListInfo' diff --git a/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts b/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts index 9d711c80ceb..674db3b6a24 100644 --- a/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts +++ b/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts @@ -44,16 +44,18 @@ export const restrictedTokensLastUpdateAtom = atomWithStorage( ) /** - * maps token list source URLs to their blocked countries + * maps token list source url to their blocked countries and consent hashes * used to hide entire token lists for users in blocked countries */ export interface RestrictedListsState { blockedCountriesPerList: Record + consentHashPerList: Record isLoaded: boolean } const initialRestrictedListsState: RestrictedListsState = { blockedCountriesPerList: {}, + consentHashPerList: {}, isLoaded: false, } diff --git a/libs/tokens/src/state/tokens/allTokensAtom.ts b/libs/tokens/src/state/tokens/allTokensAtom.ts index f7fa53c8e1b..aa5d93098cf 100644 --- a/libs/tokens/src/state/tokens/allTokensAtom.ts +++ b/libs/tokens/src/state/tokens/allTokensAtom.ts @@ -3,9 +3,11 @@ import { atom } from 'jotai' import { NATIVE_CURRENCIES, TokenWithLogo } from '@cowprotocol/common-const' import { TokenInfo } from '@cowprotocol/types' +import { blockedListSourcesAtom } from './blockedListSourcesAtom' import { favoriteTokensAtom } from './favoriteTokensAtom' import { userAddedTokensAtom } from './userAddedTokensAtom' +import { normalizeListSource } from '../../hooks/lists/useIsListBlocked' import { TokensBySymbolState, TokensMap } from '../../types' import { lowerCaseTokensMap } from '../../utils/lowerCaseTokensMap' import { parseTokenInfo } from '../../utils/parseTokenInfo' @@ -30,6 +32,7 @@ const tokensStateAtom = atom(async (get) => { const { chainId } = get(environmentAtom) const listsStatesList = await get(listsStatesListAtom) const listsEnabledState = await get(listsEnabledStateAtom) + const blockedListSources = get(blockedListSourcesAtom) return { listsCount: listsStatesList.length, @@ -39,34 +42,40 @@ const tokensStateAtom = atom(async (get) => { tokensState: [...listsStatesList] .sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER)) .reduce( - (acc, list) => { - const isListEnabled = listsEnabledState[list.source] - const lpTokenProvider = list.lpTokenProvider - list.list.tokens.forEach((token) => { - const tokenInfo = parseTokenInfo(chainId, token) - const tokenAddressKey = tokenInfo?.address.toLowerCase() + (acc, list) => { + // Skip processing tokens from blocked lists (geo-blocked or consent required) + const normalizedSource = normalizeListSource(list.source) + if (blockedListSources.has(normalizedSource)) { + return acc + } - if (!tokenInfo || !tokenAddressKey) return + const isListEnabled = listsEnabledState[list.source] + const lpTokenProvider = list.lpTokenProvider + list.list.tokens.forEach((token) => { + const tokenInfo = parseTokenInfo(chainId, token) + const tokenAddressKey = tokenInfo?.address.toLowerCase() - if (lpTokenProvider) { - tokenInfo.lpTokenProvider = lpTokenProvider - } + if (!tokenInfo || !tokenAddressKey) return - if (isListEnabled) { - if (!acc.activeTokens[tokenAddressKey]) { - acc.activeTokens[tokenAddressKey] = tokenInfo + if (lpTokenProvider) { + tokenInfo.lpTokenProvider = lpTokenProvider } - } else { - if (!acc.inactiveTokens[tokenAddressKey]) { - acc.inactiveTokens[tokenAddressKey] = tokenInfo + + if (isListEnabled) { + if (!acc.activeTokens[tokenAddressKey]) { + acc.activeTokens[tokenAddressKey] = tokenInfo + } + } else { + if (!acc.inactiveTokens[tokenAddressKey]) { + acc.inactiveTokens[tokenAddressKey] = tokenInfo + } } - } - }) + }) - return acc - }, - { activeTokens: {}, inactiveTokens: {} }, - ), + return acc + }, + { activeTokens: {}, inactiveTokens: {} }, + ), } }) diff --git a/libs/tokens/src/state/tokens/blockedListSourcesAtom.ts b/libs/tokens/src/state/tokens/blockedListSourcesAtom.ts new file mode 100644 index 00000000000..ae70bd5c13b --- /dev/null +++ b/libs/tokens/src/state/tokens/blockedListSourcesAtom.ts @@ -0,0 +1,10 @@ +import { atom } from 'jotai' + +/** + * Atom to track which list sources should be blocked/hidden. + * This is set by the frontend app based on geo status and consent. + * + * When a list source is in this set, its tokens will be filtered out + * from the active tokens list. + */ +export const blockedListSourcesAtom = atom>(new Set()) diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx index d0a159f11e0..7d812d6af15 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -18,7 +18,7 @@ const MAX_RETRIES = 1 const RETRY_DELAY_MS = 1_000 // IPFS hash of the current consent terms - shared across all restricted token issuers -const TERMS_OF_SERVICE_HASH = 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wyfiazsq' +export const RWA_CONSENT_HASH = 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wyfiazsq' export interface RestrictedTokensListUpdaterProps { isRwaGeoblockEnabled: boolean | undefined @@ -105,10 +105,13 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted const countriesPerToken: Record = {} const consentHashPerToken: Record = {} const blockedCountriesPerList: Record = {} + const consentHashPerList: Record = {} await Promise.all( restrictedLists.map(async (list) => { - blockedCountriesPerList[normalizeListSource(list.tokenListUrl)] = list.restrictedCountries + const normalizedUrl = normalizeListSource(list.tokenListUrl) + blockedCountriesPerList[normalizedUrl] = list.restrictedCountries + consentHashPerList[normalizedUrl] = RWA_CONSENT_HASH try { const tokens = await fetchTokenList(list.tokenListUrl) @@ -117,7 +120,7 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted const tokenId = getTokenId(token.chainId, token.address) tokensMap[tokenId] = token countriesPerToken[tokenId] = list.restrictedCountries - consentHashPerToken[tokenId] = TERMS_OF_SERVICE_HASH + consentHashPerToken[tokenId] = RWA_CONSENT_HASH } } catch (error) { console.error(`Failed to fetch token list for ${list.name}:`, error) @@ -134,6 +137,7 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted setRestrictedLists({ blockedCountriesPerList, + consentHashPerList, isLoaded: true, }) From 5a60f7dc2e9cd4663ce1ca30d41ef91266a23cdb Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 02:55:33 +0400 Subject: [PATCH 12/25] test: add tests for hooks --- .../lists/useFilterBlockedLists.test.tsx | 127 ++++++++++++++++++ .../src/hooks/lists/useIsListBlocked.test.tsx | 123 +++++++++++++++++ .../lists/useRestrictedListInfo.test.tsx | 117 ++++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx create mode 100644 libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx create mode 100644 libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx diff --git a/libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx b/libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx new file mode 100644 index 00000000000..ff2eb2ff4d1 --- /dev/null +++ b/libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx @@ -0,0 +1,127 @@ +import { createStore } from 'jotai' +import { Provider } from 'jotai' +import { ReactNode } from 'react' + +import { renderHook } from '@testing-library/react' + +import { useFilterBlockedLists } from './useFilterBlockedLists' +import { normalizeListSource } from './useIsListBlocked' + +import { restrictedListsAtom, RestrictedListsState } from '../../state/restrictedTokens/restrictedTokensAtom' +import { ListState } from '../../types' + +const MOCK_ONDO_LIST_URL = + 'https://raw.githubusercontent.com/ondoprotocol/cowswap-global-markets-token-list/main/tokenlist.json' +const MOCK_COWSWAP_LIST_URL = 'https://tokens.cowhub.io/cowswap.json' + +const createMockListState = (source: string, name: string): ListState => ({ + source, + priority: 1, + list: { + name, + timestamp: '2024-01-01T00:00:00Z', + version: { major: 1, minor: 0, patch: 0 }, + tokens: [], + }, + isEnabled: true, +}) + +const MOCK_ONDO_LIST = createMockListState(MOCK_ONDO_LIST_URL, 'Ondo List') +const MOCK_COWSWAP_LIST = createMockListState(MOCK_COWSWAP_LIST_URL, 'CowSwap List') + +const MOCK_RESTRICTED_LISTS_STATE: RestrictedListsState = { + blockedCountriesPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + }, + consentHashPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: 'test-consent-hash', + }, + isLoaded: true, +} + +describe('useFilterBlockedLists', () => { + function createWrapper(restrictedListsState: RestrictedListsState) { + const store = createStore() + store.set(restrictedListsAtom, restrictedListsState) + + return ({ children }: { children: ReactNode }) => {children} + } + + const allLists = [MOCK_ONDO_LIST, MOCK_COWSWAP_LIST] + + it('returns all lists when country is null', () => { + const { result } = renderHook(() => useFilterBlockedLists(allLists, null), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).toHaveLength(2) + expect(result.current).toContain(MOCK_ONDO_LIST) + expect(result.current).toContain(MOCK_COWSWAP_LIST) + }) + + it('returns all lists when restricted lists are not loaded', () => { + const notLoadedState: RestrictedListsState = { + blockedCountriesPerList: {}, + consentHashPerList: {}, + isLoaded: false, + } + + const { result } = renderHook(() => useFilterBlockedLists(allLists, 'US'), { + wrapper: createWrapper(notLoadedState), + }) + + expect(result.current).toHaveLength(2) + }) + + it('filters out blocked lists for the country', () => { + const { result } = renderHook(() => useFilterBlockedLists(allLists, 'US'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).toHaveLength(1) + expect(result.current).not.toContain(MOCK_ONDO_LIST) + expect(result.current).toContain(MOCK_COWSWAP_LIST) + }) + + it('returns all lists when country is not blocked', () => { + const { result } = renderHook(() => useFilterBlockedLists(allLists, 'DE'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).toHaveLength(2) + }) + + it('handles lowercase country codes', () => { + const { result } = renderHook(() => useFilterBlockedLists(allLists, 'us'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).toHaveLength(1) + expect(result.current).not.toContain(MOCK_ONDO_LIST) + }) + + it('returns empty array when all lists are blocked', () => { + const allBlockedState: RestrictedListsState = { + blockedCountriesPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US'], + [normalizeListSource(MOCK_COWSWAP_LIST_URL)]: ['US'], + }, + consentHashPerList: {}, + isLoaded: true, + } + + const { result } = renderHook(() => useFilterBlockedLists(allLists, 'US'), { + wrapper: createWrapper(allBlockedState), + }) + + expect(result.current).toHaveLength(0) + }) + + it('handles empty lists array', () => { + const { result } = renderHook(() => useFilterBlockedLists([], 'US'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).toHaveLength(0) + }) +}) diff --git a/libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx b/libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx new file mode 100644 index 00000000000..56ecaa902f5 --- /dev/null +++ b/libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx @@ -0,0 +1,123 @@ +import { createStore } from 'jotai' +import { Provider } from 'jotai' +import { ReactNode } from 'react' + +import { renderHook } from '@testing-library/react' + +import { normalizeListSource, useIsListBlocked } from './useIsListBlocked' + +import { restrictedListsAtom, RestrictedListsState } from '../../state/restrictedTokens/restrictedTokensAtom' + +const MOCK_ONDO_LIST_URL = + 'https://raw.githubusercontent.com/ondoprotocol/cowswap-global-markets-token-list/main/tokenlist.json' + +const MOCK_RESTRICTED_LISTS_STATE: RestrictedListsState = { + blockedCountriesPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + }, + consentHashPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wyfiazsq', + }, + isLoaded: true, +} + +describe('normalizeListSource', () => { + it('converts to lowercase', () => { + expect(normalizeListSource('HTTPS://EXAMPLE.COM/list.json')).toBe('https://example.com/list.json') + }) + + it('trims whitespace', () => { + expect(normalizeListSource(' https://example.com/list.json ')).toBe('https://example.com/list.json') + }) + + it('handles mixed case and whitespace', () => { + expect(normalizeListSource(' HTTPS://Example.COM/List.JSON ')).toBe('https://example.com/list.json') + }) +}) + +describe('useIsListBlocked', () => { + function createWrapper(restrictedListsState: RestrictedListsState) { + const store = createStore() + store.set(restrictedListsAtom, restrictedListsState) + + return ({ children }: { children: ReactNode }) => {children} + } + + it('returns isLoading: true when restricted lists are not loaded', () => { + const notLoadedState: RestrictedListsState = { + blockedCountriesPerList: {}, + consentHashPerList: {}, + isLoaded: false, + } + + const { result } = renderHook(() => useIsListBlocked(MOCK_ONDO_LIST_URL, 'US'), { + wrapper: createWrapper(notLoadedState), + }) + + expect(result.current.isLoading).toBe(true) + expect(result.current.isBlocked).toBe(false) + }) + + it('returns isBlocked: false when listSource is undefined', () => { + const { result } = renderHook(() => useIsListBlocked(undefined, 'US'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current.isBlocked).toBe(false) + expect(result.current.isLoading).toBe(true) // isLoaded check comes after listSource check + }) + + it('returns isBlocked: false when country is null', () => { + const { result } = renderHook(() => useIsListBlocked(MOCK_ONDO_LIST_URL, null), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current.isBlocked).toBe(false) + expect(result.current.isLoading).toBe(false) + }) + + it('returns isBlocked: true when country is in blocked list', () => { + const { result } = renderHook(() => useIsListBlocked(MOCK_ONDO_LIST_URL, 'US'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current.isBlocked).toBe(true) + expect(result.current.isLoading).toBe(false) + }) + + it('returns isBlocked: true for lowercase country code', () => { + const { result } = renderHook(() => useIsListBlocked(MOCK_ONDO_LIST_URL, 'us'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current.isBlocked).toBe(true) + }) + + it('returns isBlocked: false when country is not in blocked list', () => { + const { result } = renderHook(() => useIsListBlocked(MOCK_ONDO_LIST_URL, 'DE'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current.isBlocked).toBe(false) + expect(result.current.isLoading).toBe(false) + }) + + it('returns isBlocked: false when list is not in restricted lists', () => { + const { result } = renderHook(() => useIsListBlocked('https://unknown-list.com/tokens.json', 'US'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current.isBlocked).toBe(false) + expect(result.current.isLoading).toBe(false) + }) + + it('handles URL case insensitivity', () => { + const upperCaseUrl = MOCK_ONDO_LIST_URL.toUpperCase() + + const { result } = renderHook(() => useIsListBlocked(upperCaseUrl, 'US'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current.isBlocked).toBe(true) + }) +}) diff --git a/libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx b/libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx new file mode 100644 index 00000000000..67f46f9dde8 --- /dev/null +++ b/libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx @@ -0,0 +1,117 @@ +import { createStore } from 'jotai' +import { Provider } from 'jotai' +import { ReactNode } from 'react' + +import { renderHook } from '@testing-library/react' + +import { normalizeListSource } from './useIsListBlocked' +import { useRestrictedListInfo } from './useRestrictedListInfo' + +import { restrictedListsAtom, RestrictedListsState } from '../../state/restrictedTokens/restrictedTokensAtom' + +const MOCK_ONDO_LIST_URL = + 'https://raw.githubusercontent.com/ondoprotocol/cowswap-global-markets-token-list/main/tokenlist.json' +const MOCK_CONSENT_HASH = 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wyfiazsq' + +const MOCK_RESTRICTED_LISTS_STATE: RestrictedListsState = { + blockedCountriesPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + }, + consentHashPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: MOCK_CONSENT_HASH, + }, + isLoaded: true, +} + +describe('useRestrictedListInfo', () => { + function createWrapper(restrictedListsState: RestrictedListsState) { + const store = createStore() + store.set(restrictedListsAtom, restrictedListsState) + + return ({ children }: { children: ReactNode }) => {children} + } + + it('returns null when listSource is undefined', () => { + const { result } = renderHook(() => useRestrictedListInfo(undefined), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).toBeNull() + }) + + it('returns null when restricted lists are not loaded', () => { + const notLoadedState: RestrictedListsState = { + blockedCountriesPerList: {}, + consentHashPerList: {}, + isLoaded: false, + } + + const { result } = renderHook(() => useRestrictedListInfo(MOCK_ONDO_LIST_URL), { + wrapper: createWrapper(notLoadedState), + }) + + expect(result.current).toBeNull() + }) + + it('returns null when list is not in restricted lists', () => { + const { result } = renderHook(() => useRestrictedListInfo('https://unknown-list.com/tokens.json'), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).toBeNull() + }) + + it('returns restricted list info for a restricted list', () => { + const { result } = renderHook(() => useRestrictedListInfo(MOCK_ONDO_LIST_URL), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).not.toBeNull() + expect(result.current?.source).toBe(normalizeListSource(MOCK_ONDO_LIST_URL)) + expect(result.current?.blockedCountries).toEqual(['US', 'CN']) + expect(result.current?.consentHash).toBe(MOCK_CONSENT_HASH) + }) + + it('handles URL case insensitivity', () => { + const upperCaseUrl = MOCK_ONDO_LIST_URL.toUpperCase() + + const { result } = renderHook(() => useRestrictedListInfo(upperCaseUrl), { + wrapper: createWrapper(MOCK_RESTRICTED_LISTS_STATE), + }) + + expect(result.current).not.toBeNull() + expect(result.current?.blockedCountries).toEqual(['US', 'CN']) + }) + + it('returns null when blockedCountries is missing', () => { + const incompleteState: RestrictedListsState = { + blockedCountriesPerList: {}, + consentHashPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: MOCK_CONSENT_HASH, + }, + isLoaded: true, + } + + const { result } = renderHook(() => useRestrictedListInfo(MOCK_ONDO_LIST_URL), { + wrapper: createWrapper(incompleteState), + }) + + expect(result.current).toBeNull() + }) + + it('returns null when consentHash is missing', () => { + const incompleteState: RestrictedListsState = { + blockedCountriesPerList: { + [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US'], + }, + consentHashPerList: {}, + isLoaded: true, + } + + const { result } = renderHook(() => useRestrictedListInfo(MOCK_ONDO_LIST_URL), { + wrapper: createWrapper(incompleteState), + }) + + expect(result.current).toBeNull() + }) +}) From 0a86fc8109b2d8be313b23be1bb6cb0697a474fe Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 12:57:01 +0400 Subject: [PATCH 13/25] fix: build --- apps/cowswap-frontend/src/locales/en-US.po | 12 ++++++++++++ .../src/state/tokens/blockedListSourcesAtom.ts | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 8b99b4ebc5f..9beae7dc4b7 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -2815,6 +2815,10 @@ msgstr "Native currency (e.g ETH)" msgid "<0><1/> Experimental: Add DeFI interactions before and after your trade." msgstr "<0><1/> Experimental: Add DeFI interactions before and after your trade." +#: apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx +msgid "This token list is not available in your region." +msgstr "This token list is not available in your region." + #: apps/cowswap-frontend/src/common/pure/ReceiveAmountInfo/index.tsx msgid "Protocol fee" msgstr "Protocol fee" @@ -3592,6 +3596,10 @@ msgstr "CoW Protocol covers the fees and costs by executing your order at a slig msgid "With hooks you can add specific actions <0>before and <1>after your swap." msgstr "With hooks you can add specific actions <0>before and <1>after your swap." +#: apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts +msgid "This token is not available in your region." +msgstr "This token is not available in your region." + #: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx msgid "No signatures yet" msgstr "No signatures yet" @@ -3620,6 +3628,10 @@ msgstr "Approving <0>{currencySymbolOrContext} for trading" msgid "Couldn't load balances" msgstr "Couldn't load balances" +#: apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx +msgid "Not available in your region" +msgstr "Not available in your region" + #: apps/cowswap-frontend/src/pages/Account/Delegate.tsx msgid "Delegate" msgstr "Delegate" diff --git a/libs/tokens/src/state/tokens/blockedListSourcesAtom.ts b/libs/tokens/src/state/tokens/blockedListSourcesAtom.ts index ae70bd5c13b..d5fc4921d06 100644 --- a/libs/tokens/src/state/tokens/blockedListSourcesAtom.ts +++ b/libs/tokens/src/state/tokens/blockedListSourcesAtom.ts @@ -2,9 +2,9 @@ import { atom } from 'jotai' /** * Atom to track which list sources should be blocked/hidden. - * This is set by the frontend app based on geo status and consent. + * this is set by the frontend app based on geo status and consent. * - * When a list source is in this set, its tokens will be filtered out + * when a list source is in this set, its tokens will be filtered out * from the active tokens list. */ -export const blockedListSourcesAtom = atom>(new Set()) +export const blockedListSourcesAtom = atom>(new Set()) From 7d4028373e38c51461a1853ce637f28606d63d05 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 13:10:08 +0400 Subject: [PATCH 14/25] refactor: decompose component --- .../containers/ManageLists/index.tsx | 77 +----------------- .../hooks/useConsentAwareToggleList.ts | 80 +++++++++++++++++++ 2 files changed, 84 insertions(+), 73 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx index 07f1f58c55d..13fc63dbaff 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx @@ -1,37 +1,26 @@ -import { useAtomValue } from 'jotai' -import { ReactNode, useCallback, useMemo } from 'react' +import { ReactNode, useMemo } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' import { ListSearchResponse, ListState, - normalizeListSource, - restrictedListsAtom, useFilterBlockedLists, useIsListBlocked, useListsEnabledState, useRemoveList, - useToggleList, } from '@cowprotocol/tokens' import { Loader } from '@cowprotocol/ui' -import { useWalletInfo } from '@cowprotocol/wallet' import { Trans } from '@lingui/react/macro' -import { - getConsentFromCache, - rwaConsentCacheAtom, - RwaConsentKey, - useGeoCountry, - useGeoStatus, - useRwaConsentModalState, -} from 'modules/rwa' +import { useGeoCountry } from 'modules/rwa' import { CowSwapAnalyticsCategory, toCowSwapGtmEvent } from 'common/analytics/types' import * as styledEl from './styled' import { useAddListImport } from '../../hooks/useAddListImport' +import { useConsentAwareToggleList } from '../../hooks/useConsentAwareToggleList' import { useIsListRequiresConsent } from '../../hooks/useIsListRequiresConsent' import { ImportTokenListItem } from '../../pure/ImportTokenListItem' import { ListItem } from '../../pure/ListItem' @@ -48,17 +37,10 @@ export interface ManageListsProps { isListUrlValid?: boolean } -// TODO: Break down this large function into smaller functions -// eslint-disable-next-line max-lines-per-function export function ManageLists(props: ManageListsProps): ReactNode { const { lists, listSearchResponse, isListUrlValid } = props - const { account } = useWalletInfo() const country = useGeoCountry() - const geoStatus = useGeoStatus() - const restrictedLists = useAtomValue(restrictedListsAtom) - const consentCache = useAtomValue(rwaConsentCacheAtom) - const { openModal: openRwaConsentModal } = useRwaConsentModalState() // Only filter by country (blocked), NOT by consent requirement // Lists requiring consent should be visible so users can give consent @@ -67,6 +49,7 @@ export function ManageLists(props: ManageListsProps): ReactNode { const activeTokenListsIds = useListsEnabledState() const addListImport = useAddListImport() const cowAnalytics = useCowAnalytics() + const toggleList = useConsentAwareToggleList() const removeList = useRemoveList((source) => { cowAnalytics.sendEvent({ @@ -76,58 +59,6 @@ export function ManageLists(props: ManageListsProps): ReactNode { }) }) - const baseToggleList = useToggleList((enable, source) => { - cowAnalytics.sendEvent({ - category: CowSwapAnalyticsCategory.LIST, - action: `${enable ? 'Enable' : 'Disable'} List`, - label: source, - }) - }) - - // Wrapper that checks if consent is required before toggling - const toggleList = useCallback( - (list: ListState, enabled: boolean) => { - // Only check consent when trying to enable (not disable) - if (enabled) { - // Already enabled, just toggle off - baseToggleList(list, enabled) - return - } - - // Trying to enable - check if consent is required - if (!geoStatus.country && restrictedLists.isLoaded) { - const normalizedSource = normalizeListSource(list.source) - const consentHash = restrictedLists.consentHashPerList[normalizedSource] - - if (consentHash) { - // List is restricted - check if consent exists - let hasConsent = false - if (account) { - const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } - const existingConsent = getConsentFromCache(consentCache, consentKey) - hasConsent = !!existingConsent?.acceptedAt - } - - if (!hasConsent) { - // Need consent - open modal - openRwaConsentModal({ - consentHash, - onImportSuccess: () => { - // After consent, toggle the list on - baseToggleList(list, enabled) - }, - }) - return - } - } - } - - // No consent required or consent already given - baseToggleList(list, enabled) - }, - [baseToggleList, geoStatus.country, restrictedLists, account, consentCache, openRwaConsentModal], - ) - const { source, listToImport, loading } = useListSearchResponse(listSearchResponse) const { isBlocked: isListToImportBlocked } = useIsListBlocked(listToImport?.source, country) const { requiresConsent } = useIsListRequiresConsent(listToImport?.source) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts new file mode 100644 index 00000000000..37edf46bb13 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts @@ -0,0 +1,80 @@ +import { useAtomValue } from 'jotai' +import { useCallback } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' +import { ListState, normalizeListSource, restrictedListsAtom, useToggleList } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { + getConsentFromCache, + rwaConsentCacheAtom, + RwaConsentKey, + useGeoStatus, + useRwaConsentModalState, +} from 'modules/rwa' + +import { CowSwapAnalyticsCategory } from 'common/analytics/types' + +/** + * Hook that wraps toggle list functionality with consent checking. + * When trying to enable a restricted list without consent, opens the consent modal. + */ +export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) => void { + const { account } = useWalletInfo() + const geoStatus = useGeoStatus() + const restrictedLists = useAtomValue(restrictedListsAtom) + const consentCache = useAtomValue(rwaConsentCacheAtom) + const { openModal: openRwaConsentModal } = useRwaConsentModalState() + const cowAnalytics = useCowAnalytics() + + const baseToggleList = useToggleList((enable, source) => { + cowAnalytics.sendEvent({ + category: CowSwapAnalyticsCategory.LIST, + action: `${enable ? 'Enable' : 'Disable'} List`, + label: source, + }) + }) + + return useCallback( + (list: ListState, enabled: boolean) => { + // Only check consent when trying to enable (not disable) + if (enabled) { + // Already enabled, just toggle off + baseToggleList(list, enabled) + return + } + + // Trying to enable - check if consent is required + if (!geoStatus.country && restrictedLists.isLoaded) { + const normalizedSource = normalizeListSource(list.source) + const consentHash = restrictedLists.consentHashPerList[normalizedSource] + + if (consentHash) { + // List is restricted - check if consent exists + let hasConsent = false + if (account) { + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + hasConsent = !!existingConsent?.acceptedAt + } + + if (!hasConsent) { + // Need consent - open modal + openRwaConsentModal({ + consentHash, + onImportSuccess: () => { + // After consent, toggle the list on + baseToggleList(list, enabled) + }, + }) + return + } + } + } + + // No consent required or consent already given + baseToggleList(list, enabled) + }, + [baseToggleList, geoStatus.country, restrictedLists, account, consentCache, openRwaConsentModal], + ) +} From ac427eaa0b9140bd11c586ebf3edaad406683290 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 13:19:48 +0400 Subject: [PATCH 15/25] fix: don't hide list if the consent required --- .../containers/SelectTokenWidget/index.tsx | 12 +++++++----- .../tokensList/hooks/useConsentAwareToggleList.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index eda07ed9154..e5353160f1d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -16,6 +16,7 @@ import { } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' +import { t } from '@lingui/core/macro' import styled from 'styled-components/macro' import { Field } from 'legacy/state/types' @@ -127,8 +128,9 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok const { isBlocked: isListToImportBlocked } = useIsListBlocked(listToImport?.source, country) const { requiresConsent: listRequiresConsent } = useIsListRequiresConsent(listToImport?.source) - // block the list if country is blocked or if consent is required (unknown country, no consent) - const isListBlocked = isListToImportBlocked || listRequiresConsent + // without wallet: only block if country is restricted, otherwise list is always visible + // with wallet: block if country is restricted OR if consent is required (unknown country) + const isListBlocked = isListToImportBlocked || (!!account && listRequiresConsent) const openPoolPage = useCallback( (selectedPoolAddress: string) => { @@ -199,9 +201,9 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok } if (listToImport && !standalone) { - const listBlockReason = listRequiresConsent - ? 'This list requires consent before importing. Please connect your wallet.' - : undefined + // only show consent message when wallet is connected and consent is required + const listBlockReason = + account && listRequiresConsent ? t`This list requires consent before importing.` : undefined return ( { - // Only check consent when trying to enable (not disable) + // only check consent when trying to enable (not disable) if (enabled) { - // Already enabled, just toggle off + // already enabled, just toggle off baseToggleList(list, enabled) return } @@ -50,7 +50,7 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) const consentHash = restrictedLists.consentHashPerList[normalizedSource] if (consentHash) { - // List is restricted - check if consent exists + // list is restricted - check if consent exists let hasConsent = false if (account) { const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } @@ -59,11 +59,11 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) } if (!hasConsent) { - // Need consent - open modal + // need consent - open modal openRwaConsentModal({ consentHash, onImportSuccess: () => { - // After consent, toggle the list on + // after consent, toggle the list on baseToggleList(list, enabled) }, }) @@ -72,7 +72,7 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) } } - // No consent required or consent already given + // no consent required or consent already given baseToggleList(list, enabled) }, [baseToggleList, geoStatus.country, restrictedLists, account, consentCache, openRwaConsentModal], From 0127f8200a8df1208fdd894aa74645669d576e42 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 18:48:14 +0400 Subject: [PATCH 16/25] refactor: use helper for keys --- .../src/modules/rwa/hooks/useRwaTokenStatus.ts | 6 +++--- .../src/modules/tokensList/hooks/useAddListImport.ts | 6 +++--- .../tokensList/hooks/useConsentAwareToggleList.ts | 6 +++--- .../tokensList/hooks/useFilterListsWithConsent.ts | 6 +++--- .../tokensList/hooks/useIsListRequiresConsent.ts | 6 +++--- .../hooks/useRestrictedTokenImportStatus.ts | 6 +++--- .../tokensList/updaters/BlockedListSourcesUpdater.tsx | 10 +++++----- libs/tokens/src/hooks/lists/useFilterBlockedLists.ts | 8 ++++---- libs/tokens/src/hooks/lists/useIsListBlocked.ts | 11 +++++++---- libs/tokens/src/hooks/lists/useRestrictedListInfo.ts | 10 +++++----- libs/tokens/src/index.ts | 2 +- libs/tokens/src/state/tokens/allTokensAtom.ts | 6 +++--- .../updaters/RestrictedTokensListUpdater/index.tsx | 8 ++++---- 13 files changed, 47 insertions(+), 44 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts index f74a7e222c3..c673a7f1c6c 100644 --- a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts +++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useFeatureFlags } from '@cowprotocol/common-hooks' import { areTokensEqual } from '@cowprotocol/common-utils' -import { useAnyRestrictedToken, RestrictedTokenInfo } from '@cowprotocol/tokens' +import { getCountryAsKey, RestrictedTokenInfo, useAnyRestrictedToken } from '@cowprotocol/tokens' import { Nullish } from '@cowprotocol/types' import { useWalletInfo } from '@cowprotocol/wallet' import { Currency, Token } from '@uniswap/sdk-core' @@ -93,8 +93,8 @@ export function useRwaTokenStatus({ inputCurrency, outputCurrency }: UseRwaToken // 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 country = geoStatus.country.toUpperCase() - if (rwaTokenInfo.blockedCountries.has(country)) { + const countryKey = getCountryAsKey(geoStatus.country) + if (rwaTokenInfo.blockedCountries.has(countryKey)) { return RwaTokenStatus.Restricted } return RwaTokenStatus.Allowed diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts index 105d200150a..f353a921abd 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts @@ -1,7 +1,7 @@ import { useAtomValue } from 'jotai' import { useCallback } from 'react' -import { ListState, restrictedListsAtom, normalizeListSource } from '@cowprotocol/tokens' +import { getSourceAsKey, ListState, restrictedListsAtom } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' import { @@ -30,8 +30,8 @@ export function useAddListImport(): (listToImport: ListState) => void { return } - const normalizedSource = normalizeListSource(listToImport.source) - const consentHash = restrictedLists.consentHashPerList[normalizedSource] + const sourceKey = getSourceAsKey(listToImport.source) + const consentHash = restrictedLists.consentHashPerList[sourceKey] // If list is not in restricted lists, proceed normally if (!consentHash) { diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts index 9debde7d52b..4e698b60792 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts @@ -2,7 +2,7 @@ import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' -import { ListState, normalizeListSource, restrictedListsAtom, useToggleList } from '@cowprotocol/tokens' +import { getSourceAsKey, ListState, restrictedListsAtom, useToggleList } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' import { @@ -46,8 +46,8 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) // Trying to enable - check if consent is required if (!geoStatus.country && restrictedLists.isLoaded) { - const normalizedSource = normalizeListSource(list.source) - const consentHash = restrictedLists.consentHashPerList[normalizedSource] + const sourceKey = getSourceAsKey(list.source) + const consentHash = restrictedLists.consentHashPerList[sourceKey] if (consentHash) { // list is restricted - check if consent exists diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts index b108ca8b349..cab77f49f53 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useFilterListsWithConsent.ts @@ -1,7 +1,7 @@ import { useAtomValue } from 'jotai' import { useMemo } from 'react' -import { ListState, normalizeListSource, restrictedListsAtom, useFilterBlockedLists } from '@cowprotocol/tokens' +import { getSourceAsKey, ListState, restrictedListsAtom, useFilterBlockedLists } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' @@ -37,8 +37,8 @@ export function useFilterListsWithConsent(lists: ListState[]): ListState[] { } return countryFilteredLists.filter((list) => { - const normalizedSource = normalizeListSource(list.source) - const consentHash = restrictedLists.consentHashPerList[normalizedSource] + const sourceKey = getSourceAsKey(list.source) + const consentHash = restrictedLists.consentHashPerList[sourceKey] if (!consentHash) { return true diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts index 4c8cd4b1e42..bfe97c9a5c7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts @@ -1,7 +1,7 @@ import { useAtomValue } from 'jotai' import { useMemo } from 'react' -import { normalizeListSource, restrictedListsAtom } from '@cowprotocol/tokens' +import { getSourceAsKey, restrictedListsAtom } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' @@ -39,8 +39,8 @@ export function useIsListRequiresConsent(listSource: string | undefined): ListCo return { requiresConsent: false, consentHash: null, isLoading: true } } - const normalizedSource = normalizeListSource(listSource) - const consentHash = restrictedLists.consentHashPerList[normalizedSource] + const sourceKey = getSourceAsKey(listSource) + const consentHash = restrictedLists.consentHashPerList[sourceKey] // If list is not restricted, no consent required if (!consentHash) { diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts index 19e9dee0747..799cefbc575 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' -import { RestrictedTokenInfo, useRestrictedToken } from '@cowprotocol/tokens' +import { getCountryAsKey, RestrictedTokenInfo, useRestrictedToken } from '@cowprotocol/tokens' import { t } from '@lingui/core/macro' @@ -38,10 +38,10 @@ export function useRestrictedTokenImportStatus(token: TokenWithLogo | undefined) // only block import if country is known and blocked if (geoStatus.country) { - const countryUpper = geoStatus.country.toUpperCase() + const countryKey = getCountryAsKey(geoStatus.country) const blockedCountries = new Set(restrictedInfo.restrictedCountries) - if (blockedCountries.has(countryUpper)) { + if (blockedCountries.has(countryKey)) { return { status: RestrictedTokenImportStatus.Blocked, restrictedInfo, diff --git a/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx index bdd47bba2c2..f15f928d6dc 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx @@ -1,7 +1,7 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useEffect } from 'react' -import { blockedListSourcesAtom, restrictedListsAtom } from '@cowprotocol/tokens' +import { blockedListSourcesAtom, getCountryAsKey, restrictedListsAtom } from '@cowprotocol/tokens' import { useGeoStatus } from 'modules/rwa' @@ -25,11 +25,11 @@ export function BlockedListSourcesUpdater(): null { // 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 countryUpper = geoStatus.country.toUpperCase() + const countryKey = getCountryAsKey(geoStatus.country) - for (const [normalizedSource, blockedCountries] of Object.entries(restrictedLists.blockedCountriesPerList)) { - if (blockedCountries.includes(countryUpper)) { - blockedSources.add(normalizedSource) + for (const [sourceKey, blockedCountries] of Object.entries(restrictedLists.blockedCountriesPerList)) { + if (blockedCountries.includes(countryKey)) { + blockedSources.add(sourceKey) } } } diff --git a/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts b/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts index 16777302619..ed829f6f1ea 100644 --- a/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts +++ b/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts @@ -1,7 +1,7 @@ import { useAtomValue } from 'jotai' import { useMemo } from 'react' -import { normalizeListSource } from './useIsListBlocked' +import { getCountryAsKey, getSourceAsKey } from './useIsListBlocked' import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' import { ListState } from '../../types' @@ -17,16 +17,16 @@ export function useFilterBlockedLists(lists: ListState[], country: string | null return lists } - const countryUpper = country.toUpperCase() + const countryKey = getCountryAsKey(country) return lists.filter((list) => { - const blockedCountries = restrictedLists.blockedCountriesPerList[normalizeListSource(list.source)] + const blockedCountries = restrictedLists.blockedCountriesPerList[getSourceAsKey(list.source)] if (!blockedCountries) { return true } - return !blockedCountries.includes(countryUpper) + return !blockedCountries.includes(countryKey) }) }, [lists, country, restrictedLists]) } diff --git a/libs/tokens/src/hooks/lists/useIsListBlocked.ts b/libs/tokens/src/hooks/lists/useIsListBlocked.ts index 516c263bbae..3577ea738b0 100644 --- a/libs/tokens/src/hooks/lists/useIsListBlocked.ts +++ b/libs/tokens/src/hooks/lists/useIsListBlocked.ts @@ -3,10 +3,14 @@ import { useMemo } from 'react' import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' -export function normalizeListSource(source: string): string { +export function getSourceAsKey(source: string): string { return source.toLowerCase().trim() } +export function getCountryAsKey(country: string): string { + return country.toUpperCase() +} + export interface ListBlockedResult { isBlocked: boolean isLoading: boolean @@ -27,14 +31,13 @@ export function useIsListBlocked(listSource: string | undefined, country: string return { isBlocked: false, isLoading: false } } - const blockedCountries = restrictedLists.blockedCountriesPerList[normalizeListSource(listSource)] + const blockedCountries = restrictedLists.blockedCountriesPerList[getSourceAsKey(listSource)] if (!blockedCountries) { return { isBlocked: false, isLoading: false } } - const countryUpper = country.toUpperCase() - const isBlocked = blockedCountries.includes(countryUpper) + const isBlocked = blockedCountries.includes(getCountryAsKey(country)) return { isBlocked, isLoading: false } }, [listSource, country, restrictedLists]) } diff --git a/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts b/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts index 37c75d7114f..00b4cab2d09 100644 --- a/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts +++ b/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts @@ -1,7 +1,7 @@ import { useAtomValue } from 'jotai' import { useMemo } from 'react' -import { normalizeListSource } from './useIsListBlocked' +import { getSourceAsKey } from './useIsListBlocked' import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' @@ -19,16 +19,16 @@ export function useRestrictedListInfo(listSource: string | undefined): Restricte return null } - const normalizedSource = normalizeListSource(listSource) - const blockedCountries = restrictedLists.blockedCountriesPerList[normalizedSource] - const consentHash = restrictedLists.consentHashPerList[normalizedSource] + const sourceKey = getSourceAsKey(listSource) + const blockedCountries = restrictedLists.blockedCountriesPerList[sourceKey] + const consentHash = restrictedLists.consentHashPerList[sourceKey] if (!blockedCountries || !consentHash) { return null } return { - source: normalizedSource, + source: sourceKey, blockedCountries, consentHash, } diff --git a/libs/tokens/src/index.ts b/libs/tokens/src/index.ts index 953c369faed..33917c9b56d 100644 --- a/libs/tokens/src/index.ts +++ b/libs/tokens/src/index.ts @@ -76,7 +76,7 @@ export { DEFAULT_TOKENS_LISTS } from './const/tokensLists' export { RWA_CONSENT_HASH } from './updaters/RestrictedTokensListUpdater' export { useIsAnyOfTokensOndo } from './hooks/lists/useIsAnyOfTokensOndo' export { useFilterBlockedLists } from './hooks/lists/useFilterBlockedLists' -export { useIsListBlocked, normalizeListSource } from './hooks/lists/useIsListBlocked' +export { useIsListBlocked, getSourceAsKey, getCountryAsKey } from './hooks/lists/useIsListBlocked' export { useRestrictedListInfo } from './hooks/lists/useRestrictedListInfo' // Types diff --git a/libs/tokens/src/state/tokens/allTokensAtom.ts b/libs/tokens/src/state/tokens/allTokensAtom.ts index aa5d93098cf..b108f6e881e 100644 --- a/libs/tokens/src/state/tokens/allTokensAtom.ts +++ b/libs/tokens/src/state/tokens/allTokensAtom.ts @@ -7,7 +7,7 @@ import { blockedListSourcesAtom } from './blockedListSourcesAtom' import { favoriteTokensAtom } from './favoriteTokensAtom' import { userAddedTokensAtom } from './userAddedTokensAtom' -import { normalizeListSource } from '../../hooks/lists/useIsListBlocked' +import { getSourceAsKey } from '../../hooks/lists/useIsListBlocked' import { TokensBySymbolState, TokensMap } from '../../types' import { lowerCaseTokensMap } from '../../utils/lowerCaseTokensMap' import { parseTokenInfo } from '../../utils/parseTokenInfo' @@ -44,8 +44,8 @@ const tokensStateAtom = atom(async (get) => { .reduce( (acc, list) => { // Skip processing tokens from blocked lists (geo-blocked or consent required) - const normalizedSource = normalizeListSource(list.source) - if (blockedListSources.has(normalizedSource)) { + const sourceKey = getSourceAsKey(list.source) + if (blockedListSources.has(sourceKey)) { return acc } diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx index 7d812d6af15..570a3aefb05 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react' import { getRestrictedTokenLists } from '@cowprotocol/core' import { TokenInfo } from '@cowprotocol/types' -import { normalizeListSource } from '../../hooks/lists/useIsListBlocked' +import { getSourceAsKey } from '../../hooks/lists/useIsListBlocked' import { useRestrictedTokensCache } from '../../hooks/useRestrictedTokensCache' import { getTokenId, @@ -109,9 +109,9 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted await Promise.all( restrictedLists.map(async (list) => { - const normalizedUrl = normalizeListSource(list.tokenListUrl) - blockedCountriesPerList[normalizedUrl] = list.restrictedCountries - consentHashPerList[normalizedUrl] = RWA_CONSENT_HASH + const urlKey = getSourceAsKey(list.tokenListUrl) + blockedCountriesPerList[urlKey] = list.restrictedCountries + consentHashPerList[urlKey] = RWA_CONSENT_HASH try { const tokens = await fetchTokenList(list.tokenListUrl) From 911160ae50db8e432884c9a006d4b363e690240d Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 19:56:15 +0400 Subject: [PATCH 17/25] test: fix cases --- .../hooks/lists/useFilterBlockedLists.test.tsx | 10 +++++----- .../src/hooks/lists/useIsListBlocked.test.tsx | 16 ++++++++-------- .../hooks/lists/useRestrictedListInfo.test.tsx | 12 ++++++------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx b/libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx index ff2eb2ff4d1..4282ea2f707 100644 --- a/libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx +++ b/libs/tokens/src/hooks/lists/useFilterBlockedLists.test.tsx @@ -5,7 +5,7 @@ import { ReactNode } from 'react' import { renderHook } from '@testing-library/react' import { useFilterBlockedLists } from './useFilterBlockedLists' -import { normalizeListSource } from './useIsListBlocked' +import { getSourceAsKey } from './useIsListBlocked' import { restrictedListsAtom, RestrictedListsState } from '../../state/restrictedTokens/restrictedTokensAtom' import { ListState } from '../../types' @@ -31,10 +31,10 @@ const MOCK_COWSWAP_LIST = createMockListState(MOCK_COWSWAP_LIST_URL, 'CowSwap Li const MOCK_RESTRICTED_LISTS_STATE: RestrictedListsState = { blockedCountriesPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], }, consentHashPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: 'test-consent-hash', + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: 'test-consent-hash', }, isLoaded: true, } @@ -103,8 +103,8 @@ describe('useFilterBlockedLists', () => { it('returns empty array when all lists are blocked', () => { const allBlockedState: RestrictedListsState = { blockedCountriesPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US'], - [normalizeListSource(MOCK_COWSWAP_LIST_URL)]: ['US'], + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US'], + [getSourceAsKey(MOCK_COWSWAP_LIST_URL)]: ['US'], }, consentHashPerList: {}, isLoaded: true, diff --git a/libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx b/libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx index 56ecaa902f5..1c6c997bb65 100644 --- a/libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx +++ b/libs/tokens/src/hooks/lists/useIsListBlocked.test.tsx @@ -4,7 +4,7 @@ import { ReactNode } from 'react' import { renderHook } from '@testing-library/react' -import { normalizeListSource, useIsListBlocked } from './useIsListBlocked' +import { getSourceAsKey, useIsListBlocked } from './useIsListBlocked' import { restrictedListsAtom, RestrictedListsState } from '../../state/restrictedTokens/restrictedTokensAtom' @@ -13,25 +13,25 @@ const MOCK_ONDO_LIST_URL = const MOCK_RESTRICTED_LISTS_STATE: RestrictedListsState = { blockedCountriesPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], }, consentHashPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wyfiazsq', + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wyfiazsq', }, isLoaded: true, } -describe('normalizeListSource', () => { +describe('getSourceAsKey', () => { it('converts to lowercase', () => { - expect(normalizeListSource('HTTPS://EXAMPLE.COM/list.json')).toBe('https://example.com/list.json') + expect(getSourceAsKey('HTTPS://EXAMPLE.COM/list.json')).toBe('https://example.com/list.json') }) it('trims whitespace', () => { - expect(normalizeListSource(' https://example.com/list.json ')).toBe('https://example.com/list.json') + expect(getSourceAsKey(' https://example.com/list.json ')).toBe('https://example.com/list.json') }) it('handles mixed case and whitespace', () => { - expect(normalizeListSource(' HTTPS://Example.COM/List.JSON ')).toBe('https://example.com/list.json') + expect(getSourceAsKey(' HTTPS://Example.COM/List.JSON ')).toBe('https://example.com/list.json') }) }) @@ -64,7 +64,7 @@ describe('useIsListBlocked', () => { }) expect(result.current.isBlocked).toBe(false) - expect(result.current.isLoading).toBe(true) // isLoaded check comes after listSource check + expect(result.current.isLoading).toBe(false) // returns early when listSource is undefined }) it('returns isBlocked: false when country is null', () => { diff --git a/libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx b/libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx index 67f46f9dde8..3d87a3e75da 100644 --- a/libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx +++ b/libs/tokens/src/hooks/lists/useRestrictedListInfo.test.tsx @@ -4,7 +4,7 @@ import { ReactNode } from 'react' import { renderHook } from '@testing-library/react' -import { normalizeListSource } from './useIsListBlocked' +import { getSourceAsKey } from './useIsListBlocked' import { useRestrictedListInfo } from './useRestrictedListInfo' import { restrictedListsAtom, RestrictedListsState } from '../../state/restrictedTokens/restrictedTokensAtom' @@ -15,10 +15,10 @@ const MOCK_CONSENT_HASH = 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wy const MOCK_RESTRICTED_LISTS_STATE: RestrictedListsState = { blockedCountriesPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], }, consentHashPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: MOCK_CONSENT_HASH, + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: MOCK_CONSENT_HASH, }, isLoaded: true, } @@ -67,7 +67,7 @@ describe('useRestrictedListInfo', () => { }) expect(result.current).not.toBeNull() - expect(result.current?.source).toBe(normalizeListSource(MOCK_ONDO_LIST_URL)) + expect(result.current?.source).toBe(getSourceAsKey(MOCK_ONDO_LIST_URL)) expect(result.current?.blockedCountries).toEqual(['US', 'CN']) expect(result.current?.consentHash).toBe(MOCK_CONSENT_HASH) }) @@ -87,7 +87,7 @@ describe('useRestrictedListInfo', () => { const incompleteState: RestrictedListsState = { blockedCountriesPerList: {}, consentHashPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: MOCK_CONSENT_HASH, + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: MOCK_CONSENT_HASH, }, isLoaded: true, } @@ -102,7 +102,7 @@ describe('useRestrictedListInfo', () => { it('returns null when consentHash is missing', () => { const incompleteState: RestrictedListsState = { blockedCountriesPerList: { - [normalizeListSource(MOCK_ONDO_LIST_URL)]: ['US'], + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US'], }, consentHashPerList: {}, isLoaded: true, From fe662c9d2a682a62d73d5d268995bda77dc672cf Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 21:25:28 +0400 Subject: [PATCH 18/25] fix: don't require consent when wallet is disconnected --- .../containers/ManageLists/index.tsx | 12 +++-------- .../tokensList/hooks/useAddListImport.ts | 21 ++++++++++++------- .../hooks/useAddTokenImportCallback.ts | 21 ++++++++++++------- .../hooks/useConsentAwareToggleList.ts | 18 +++++++++------- .../hooks/useIsListRequiresConsent.ts | 4 ++-- 5 files changed, 41 insertions(+), 35 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx index 13fc63dbaff..f0bb19e8557 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx @@ -21,7 +21,6 @@ import * as styledEl from './styled' import { useAddListImport } from '../../hooks/useAddListImport' import { useConsentAwareToggleList } from '../../hooks/useConsentAwareToggleList' -import { useIsListRequiresConsent } from '../../hooks/useIsListRequiresConsent' import { ImportTokenListItem } from '../../pure/ImportTokenListItem' import { ListItem } from '../../pure/ListItem' @@ -42,8 +41,8 @@ export function ManageLists(props: ManageListsProps): ReactNode { const country = useGeoCountry() - // Only filter by country (blocked), NOT by consent requirement - // Lists requiring consent should be visible so users can give consent + // only filter by country (blocked), NOT by consent requirement + // lists requiring consent should be visible so users can give consent const filteredLists = useFilterBlockedLists(lists, country) const activeTokenListsIds = useListsEnabledState() @@ -60,11 +59,7 @@ export function ManageLists(props: ManageListsProps): ReactNode { }) const { source, listToImport, loading } = useListSearchResponse(listSearchResponse) - const { isBlocked: isListToImportBlocked } = useIsListBlocked(listToImport?.source, country) - const { requiresConsent } = useIsListRequiresConsent(listToImport?.source) - - // Block the list if country is blocked OR if consent is required (unknown country, no consent) - const isBlocked = isListToImportBlocked || requiresConsent + const { isBlocked } = useIsListBlocked(listToImport?.source, country) return ( @@ -84,7 +79,6 @@ export function ManageLists(props: ManageListsProps): ReactNode { source={source} list={listToImport} isBlocked={isBlocked} - blockReason={requiresConsent ? 'Consent required. Connect wallet to proceed.' : undefined} data-click-event={toCowSwapGtmEvent({ category: CowSwapAnalyticsCategory.LIST, action: 'Import List', diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts index f353a921abd..8980e6da9a0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts @@ -45,16 +45,21 @@ export function useAddListImport(): (listToImport: ListState) => void { return } - // Country unknown - need consent before import - if (account) { - const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } - const existingConsent = getConsentFromCache(consentCache, consentKey) - if (existingConsent?.acceptedAt) { - updateSelectTokenWidget({ listToImport }) - return - } + // Country unknown - if no wallet, allow import (consent check deferred to trade time) + if (!account) { + updateSelectTokenWidget({ listToImport }) + return + } + + // Wallet connected - check if consent already given + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + if (existingConsent?.acceptedAt) { + updateSelectTokenWidget({ listToImport }) + return } + // Wallet connected but no consent - open modal openRwaConsentModal({ consentHash, onImportSuccess: () => { diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts index 9aaba77d528..cf9f7957882 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts @@ -43,16 +43,21 @@ export function useAddTokenImportCallback(): (tokenToImport: TokenWithLogo) => v return } - // country unknown- need consent before import - if (account) { - const consentKey: RwaConsentKey = { wallet: account, ipfsHash: restrictedInfo.consentHash } - const existingConsent = getConsentFromCache(consentCache, consentKey) - if (existingConsent?.acceptedAt) { - updateSelectTokenWidget({ tokenToImport }) - return - } + // country unknown - if no wallet allow import + if (!account) { + updateSelectTokenWidget({ tokenToImport }) + return + } + + // wallet connected - check if consent already given + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: restrictedInfo.consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + if (existingConsent?.acceptedAt) { + updateSelectTokenWidget({ tokenToImport }) + return } + // wallet connected but no consent - open modal openRwaConsentModal({ consentHash: restrictedInfo.consentHash, token: tokenToImport, diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts index 4e698b60792..6a54ad7dac0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts @@ -50,16 +50,18 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) const consentHash = restrictedLists.consentHashPerList[sourceKey] if (consentHash) { - // list is restricted - check if consent exists - let hasConsent = false - if (account) { - const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } - const existingConsent = getConsentFromCache(consentCache, consentKey) - hasConsent = !!existingConsent?.acceptedAt + // list is restricted - if no wallet, allow toggle (consent check deferred to trade time) + if (!account) { + baseToggleList(list, enabled) + return } - if (!hasConsent) { - // need consent - open modal + // Wallet connected - check if consent exists + const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } + const existingConsent = getConsentFromCache(consentCache, consentKey) + + if (!existingConsent?.acceptedAt) { + // Wallet connected but no consent - open modal openRwaConsentModal({ consentHash, onImportSuccess: () => { diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts index bfe97c9a5c7..531da110792 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts @@ -53,9 +53,9 @@ 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) { - // No wallet connected - consent required - return { requiresConsent: true, consentHash, isLoading: false } + return { requiresConsent: false, consentHash, isLoading: false } } const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } From a752b85f1f8ca568204158362971da8faf50c320 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 22:39:04 +0400 Subject: [PATCH 19/25] fix: image size in importing --- .../modules/tokensList/pure/ImportTokenListItem/styled.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts index 6cc95d0fa22..6e3b0e3c1a9 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/styled.ts @@ -21,7 +21,13 @@ export const BlockedInfo = styled.div` display: flex; flex-direction: row; gap: 8px; - align-items: center; + align-items: flex-start; color: var(${UI.COLOR_DANGER_TEXT}); font-size: 13px; + + > svg { + flex-shrink: 0; + width: 16px; + height: 16px; + } ` From 7b7a567ee7271bc53fde9af6f1bb6d7160fb21b3 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 23:25:45 +0400 Subject: [PATCH 20/25] refactor: small improvements --- .../containers/SelectTokenWidget/index.tsx | 10 +++++----- .../tokensList/hooks/useConsentAwareToggleList.ts | 12 ++++-------- .../tokensList/hooks/useIsListRequiresConsent.ts | 13 ++----------- .../tokensList/pure/ImportTokenListItem/index.tsx | 6 +++--- .../updaters/BlockedListSourcesUpdater.tsx | 10 +++++----- 5 files changed, 19 insertions(+), 32 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index e5353160f1d..032133aaedb 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -125,12 +125,12 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok const { isImportDisabled, blockReason } = useRestrictedTokenImportStatus(tokenToImport) const country = useGeoCountry() - const { isBlocked: isListToImportBlocked } = useIsListBlocked(listToImport?.source, country) - const { requiresConsent: listRequiresConsent } = useIsListRequiresConsent(listToImport?.source) + const { isBlocked } = useIsListBlocked(listToImport?.source, country) + const { requiresConsent } = useIsListRequiresConsent(listToImport?.source) // without wallet: only block if country is restricted, otherwise list is always visible - // with wallet: block if country is restricted OR if consent is required (unknown country) - const isListBlocked = isListToImportBlocked || (!!account && listRequiresConsent) + // with wallet: block if country is restricted or if consent is required (unknown country) + const isListBlocked = isBlocked || (!!account && requiresConsent) const openPoolPage = useCallback( (selectedPoolAddress: string) => { @@ -203,7 +203,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok if (listToImport && !standalone) { // only show consent message when wallet is connected and consent is required const listBlockReason = - account && listRequiresConsent ? t`This list requires consent before importing.` : undefined + account && requiresConsent ? t`This list requires consent before importing.` : undefined return ( void { const { account } = useWalletInfo() const geoStatus = useGeoStatus() @@ -39,12 +36,11 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) (list: ListState, enabled: boolean) => { // only check consent when trying to enable (not disable) if (enabled) { - // already enabled, just toggle off baseToggleList(list, enabled) return } - // Trying to enable - check if consent is required + // trying to enable - check if consent is required if (!geoStatus.country && restrictedLists.isLoaded) { const sourceKey = getSourceAsKey(list.source) const consentHash = restrictedLists.consentHashPerList[sourceKey] @@ -56,12 +52,12 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) return } - // Wallet connected - check if consent exists + // wallet connected - check if consent exists const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash } const existingConsent = getConsentFromCache(consentCache, consentKey) if (!existingConsent?.acceptedAt) { - // Wallet connected but no consent - open modal + // wallet connected but no consent - open modal openRwaConsentModal({ consentHash, onImportSuccess: () => { diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts index 531da110792..d95140a5a38 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts @@ -7,21 +7,12 @@ import { useWalletInfo } from '@cowprotocol/wallet' import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' export interface ListConsentResult { - /** True if list is restricted and country is unknown and no consent given */ - requiresConsent: boolean - /** The consent hash for this list (if restricted) */ + requiresConsent: Boolean consentHash: string | null - /** True if we're still loading geo/restricted data */ isLoading: boolean } -/** - * Checks if a list requires consent before it can be shown/imported. - * This is true when: - * 1. The list is in the restricted lists - * 2. The user's country is unknown - * 3. The user has not given consent yet - */ +// check if a list requires consent before it can be shown/imported export function useIsListRequiresConsent(listSource: string | undefined): ListConsentResult { const { account } = useWalletInfo() const geoStatus = useGeoStatus() diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx index 6c301ff1e3e..8b6a91d665e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx @@ -1,3 +1,5 @@ +import { ReactElement } from 'react' + import { ListState } from '@cowprotocol/tokens' import { Trans } from '@lingui/react/macro' @@ -16,9 +18,7 @@ export interface ImportTokenListItemProps { importList(list: ListState): void } -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function ImportTokenListItem(props: ImportTokenListItemProps) { +export function ImportTokenListItem(props: ImportTokenListItemProps): ReactElement { const { list, source, importList, isBlocked, blockReason } = props return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx index f15f928d6dc..acaf9bffe42 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/updaters/BlockedListSourcesUpdater.tsx @@ -6,9 +6,9 @@ import { blockedListSourcesAtom, getCountryAsKey, restrictedListsAtom } from '@c import { useGeoStatus } from 'modules/rwa' /** - * Updates 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) + * 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) */ export function BlockedListSourcesUpdater(): null { const geoStatus = useGeoStatus() @@ -22,8 +22,8 @@ export function BlockedListSourcesUpdater(): null { 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) + // 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) From 2c311470c8bdcde941f87d5f5271c1d8dbb580a9 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Tue, 30 Dec 2025 23:34:13 +0400 Subject: [PATCH 21/25] fix: types --- .../src/modules/tokensList/hooks/useIsListRequiresConsent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts index d95140a5a38..af10296d84b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts @@ -7,7 +7,7 @@ import { useWalletInfo } from '@cowprotocol/wallet' import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' export interface ListConsentResult { - requiresConsent: Boolean + requiresConsent: boolean consentHash: string | null isLoading: boolean } From d0576d715946c064474b2b94c0e53be584d080d0 Mon Sep 17 00:00:00 2001 From: Denis Makarov Date: Thu, 8 Jan 2026 22:43:25 +0400 Subject: [PATCH 22/25] feat: refetch geodata when user changes wallet address (#6780) * feat: refetch geodata when user changes wallet address * refactor: use areAddressesEqual --- .../application/containers/App/Updaters.tsx | 2 ++ .../cowswap-frontend/src/modules/rwa/index.ts | 1 + .../src/modules/rwa/state/geoDataAtom.ts | 36 +++++++++++++------ .../modules/rwa/updaters/GeoDataUpdater.tsx | 23 ++++++++++++ 4 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/rwa/updaters/GeoDataUpdater.tsx 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 b479a7c4e1e..171ffd95362 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx @@ -31,6 +31,7 @@ import { ProgressBarExecutingOrdersUpdater, } from 'modules/orderProgressBar' import { OrdersNotificationsUpdater } from 'modules/orders' +import { GeoDataUpdater } from 'modules/rwa' import { BlockedListSourcesUpdater, useSourceChainId } from 'modules/tokensList' import { TradeType, useTradeTypeInfo } from 'modules/trade' import { UsdPricesUpdater } from 'modules/usdAmount' @@ -116,6 +117,7 @@ export function Updaters(): ReactNode { /> + diff --git a/apps/cowswap-frontend/src/modules/rwa/index.ts b/apps/cowswap-frontend/src/modules/rwa/index.ts index 61614e1adf1..d53031811fa 100644 --- a/apps/cowswap-frontend/src/modules/rwa/index.ts +++ b/apps/cowswap-frontend/src/modules/rwa/index.ts @@ -9,3 +9,4 @@ export * from './hooks/useGeoStatus' export * from './hooks/useRwaTokenStatus' export * from './pure/RwaConsentModal' export * from './containers/RwaConsentModalContainer' +export * from './updaters/GeoDataUpdater' diff --git a/apps/cowswap-frontend/src/modules/rwa/state/geoDataAtom.ts b/apps/cowswap-frontend/src/modules/rwa/state/geoDataAtom.ts index 5aa2f5170b2..7dfb51d45bc 100644 --- a/apps/cowswap-frontend/src/modules/rwa/state/geoDataAtom.ts +++ b/apps/cowswap-frontend/src/modules/rwa/state/geoDataAtom.ts @@ -14,30 +14,44 @@ const initialGeoData: GeoData = { export const geoDataAtom = atom(initialGeoData) -export const fetchGeoDataAtom = atom(null, async (get, set) => { - const current = get(geoDataAtom) - - // Don't fetch if already loaded or loading - if (current.country !== null || current.isLoading) { - return - } - - set(geoDataAtom, { ...current, isLoading: true }) +async function doFetchGeoData(set: (update: GeoData) => void, current: GeoData): Promise { + set({ ...current, isLoading: true }) try { const response = await fetch('https://api.country.is') const data = await response.json() - set(geoDataAtom, { + set({ country: data.country || null, isLoading: false, error: null, }) } catch (error) { - set(geoDataAtom, { + set({ country: null, isLoading: false, error: error instanceof Error ? error.message : 'Failed to fetch geo data', }) } +} + +export const fetchGeoDataAtom = atom(null, async (get, set) => { + const current = get(geoDataAtom) + + if (current.country !== null || current.isLoading) { + return + } + + await doFetchGeoData((update) => set(geoDataAtom, update), current) +}) + +// for cases when user changes wallet +export const refetchGeoDataAtom = atom(null, async (get, set) => { + const current = get(geoDataAtom) + + if (current.isLoading) { + return + } + + await doFetchGeoData((update) => set(geoDataAtom, update), current) }) diff --git a/apps/cowswap-frontend/src/modules/rwa/updaters/GeoDataUpdater.tsx b/apps/cowswap-frontend/src/modules/rwa/updaters/GeoDataUpdater.tsx new file mode 100644 index 00000000000..6ce9f7bd419 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/rwa/updaters/GeoDataUpdater.tsx @@ -0,0 +1,23 @@ +import { useSetAtom } from 'jotai' +import { useEffect } from 'react' + +import { usePrevious } from '@cowprotocol/common-hooks' +import { areAddressesEqual } from '@cowprotocol/common-utils' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { refetchGeoDataAtom } from '../state/geoDataAtom' + +export function GeoDataUpdater(): null { + const { account } = useWalletInfo() + const refetchGeoData = useSetAtom(refetchGeoDataAtom) + const prevAccount = usePrevious(account) + + useEffect(() => { + // only refetch when wallet actually changes (not on initial render) + if (!areAddressesEqual(prevAccount, account)) { + refetchGeoData() + } + }, [account, prevAccount, refetchGeoData]) + + return null +} From 9cb2ebe1cee72d6115a6ab84a430f30a13444950 Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 8 Jan 2026 23:03:27 +0400 Subject: [PATCH 23/25] fix: handle import token with consents from url --- apps/cowswap-frontend/src/locales/en-US.po | 5 +- .../hooks/useRestrictedTokensImportStatus.ts | 70 +++++++++++++++++++ .../src/modules/tokensList/index.ts | 1 + .../TradeWidget/TradeWidgetModals.tsx | 46 +++++++++++- 4 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 107e2ad5dac..56ce750e635 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -3598,11 +3598,12 @@ msgstr "CoW Protocol covers the fees and costs by executing your order at a slig #: apps/cowswap-frontend/src/modules/hooksStore/containers/HooksStoreWidget/index.tsx msgid "With hooks you can add specific actions <0>before and <1>after your swap." -msgstr "With hooks you can add specific actions <0>before and <1>after your swap." +msgstr "With hooks you can add specific actions <0>before and <1>after your swap.<<<<<<< Updated upstream" #: apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts +#: apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts msgid "This token is not available in your region." -msgstr "This token is not available in your region." +msgstr "This token is not available in your region.>>>>>>> Stashed changes>>>>>>> Stashed changes" #: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx msgid "No signatures yet" diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts new file mode 100644 index 00000000000..e4a84ddc0ec --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts @@ -0,0 +1,70 @@ +import { useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { t } from '@lingui/core/macro' + +import { RwaTokenInfo, RwaTokenStatus, useRwaTokenStatus } from 'modules/rwa' + +export interface RestrictedTokensImportResult { + isImportDisabled: boolean + blockReason: string | null + restrictedTokenInfo: RwaTokenInfo | null + /** When true, consent modal should be shown before import */ + requiresConsent: boolean + /** The first restricted token that needs consent */ + tokenNeedingConsent: TokenWithLogo | null +} + +const NOT_RESTRICTED_RESULT: RestrictedTokensImportResult = { + isImportDisabled: false, + blockReason: null, + restrictedTokenInfo: null, + requiresConsent: false, + tokenNeedingConsent: null, +} + +/** + * Check if any of the tokens are restricted for the user's country (for auto-import flow) + * Reuses useRwaTokenStatus for consistent restriction logic + */ +export function useRestrictedTokensImportStatus(tokens: TokenWithLogo[]): RestrictedTokensImportResult { + const inputToken = tokens[0] + const outputToken = tokens[1] + + const { status, rwaTokenInfo } = useRwaTokenStatus({ + inputCurrency: inputToken, + outputCurrency: outputToken, + }) + + return useMemo(() => { + if (tokens.length === 0) { + return NOT_RESTRICTED_RESULT + } + + switch (status) { + case RwaTokenStatus.Restricted: + return { + isImportDisabled: true, + blockReason: t`This token is not available in your region.`, + restrictedTokenInfo: rwaTokenInfo, + requiresConsent: false, + tokenNeedingConsent: null, + } + + case RwaTokenStatus.RequiredConsent: + return { + isImportDisabled: false, + blockReason: null, + restrictedTokenInfo: rwaTokenInfo, + requiresConsent: true, + tokenNeedingConsent: rwaTokenInfo?.token ? TokenWithLogo.fromToken(rwaTokenInfo.token) : null, + } + + case RwaTokenStatus.Allowed: + case RwaTokenStatus.ConsentIsSigned: + default: + return NOT_RESTRICTED_RESULT + } + }, [tokens.length, status, rwaTokenInfo]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/index.ts b/apps/cowswap-frontend/src/modules/tokensList/index.ts index 4483317520d..544088469e6 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/index.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts @@ -12,3 +12,4 @@ export { useUpdateSelectTokenWidgetState } from './hooks/useUpdateSelectTokenWid export { useOnTokenListAddingError } from './hooks/useOnTokenListAddingError' export { useTokenListAddingError } from './hooks/useTokenListAddingError' export { useSourceChainId } from './hooks/useSourceChainId' +export { useRestrictedTokensImportStatus } from './hooks/useRestrictedTokensImportStatus' diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx index 5360729ee5c..09b810e3e6c 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx @@ -20,6 +20,7 @@ import { useSelectTokenWidgetState, useTokenListAddingError, useUpdateSelectTokenWidgetState, + useRestrictedTokensImportStatus, } from 'modules/tokensList' import { useZeroApproveModalState, ZeroApprovalModal } from 'modules/zeroApproval' @@ -67,6 +68,9 @@ export function TradeWidgetModals({ tokensToImport, modalState: { isModalOpen: isAutoImportModalOpen, closeModal: closeAutoImportModal }, } = useAutoImportTokensState(rawState?.inputCurrencyId, rawState?.outputCurrencyId) + const { isImportDisabled, blockReason, requiresConsent, restrictedTokenInfo, tokenNeedingConsent } = + useRestrictedTokensImportStatus(tokensToImport) + const { openModal: openRwaConsentModal } = useRwaConsentModalState() const { onDismiss: closeTradeConfirm } = useTradeConfirmActions() const updateSelectTokenWidgetState = useUpdateSelectTokenWidgetState() @@ -119,6 +123,34 @@ export function TradeWidgetModals({ resetAllScreens(isOutputTokenSelectorRef.current) }, [chainId, resetAllScreens]) + /** + * If auto-import modal is open and consent is required, + * open the RWA consent modal instead + */ + useEffect(() => { + if (isAutoImportModalOpen && requiresConsent && restrictedTokenInfo && tokenNeedingConsent) { + openRwaConsentModal({ + consentHash: restrictedTokenInfo.consentHash, + token: tokenNeedingConsent, + pendingImportTokens: tokensToImport, + onImportSuccess: () => { + // After consent, import the tokens + importTokenCallback(tokensToImport) + closeAutoImportModal() + }, + }) + } + }, [ + isAutoImportModalOpen, + requiresConsent, + restrictedTokenInfo, + tokenNeedingConsent, + tokensToImport, + openRwaConsentModal, + importTokenCallback, + closeAutoImportModal, + ]) + if (genericModal) { return genericModal } @@ -139,8 +171,18 @@ export function TradeWidgetModals({ return selectTokenWidget } - if (isAutoImportModalOpen) { - return + // Show import modal only if consent is not required + // (if consent is required, the useEffect above will open the consent modal) + if (isAutoImportModalOpen && !requiresConsent) { + return ( + + ) } if (isWrapNativeOpen) { From b2be8dd1ca8059657bbc6818fb3f6ee3bca849ae Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 8 Jan 2026 23:24:03 +0400 Subject: [PATCH 24/25] fix: handle disabling without consents --- .../modules/tokensList/hooks/useConsentAwareToggleList.ts | 5 +++-- .../src/modules/tokensList/pure/ListItem/index.tsx | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts index ef63ad4a3c0..e86d35a09c2 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts @@ -34,13 +34,14 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) return useCallback( (list: ListState, enabled: boolean) => { - // only check consent when trying to enable (not disable) - if (enabled) { + // always allow disabling a list without consent + if (!enabled) { baseToggleList(list, enabled) return } // trying to enable - check if consent is required + // only require consent when country is unknown (blocked countries are handled by hiding the list) if (!geoStatus.country && restrictedLists.isLoaded) { const sourceKey = getSourceAsKey(list.source) const consentHash = restrictedLists.consentHashPerList[sourceKey] diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ListItem/index.tsx index 9b537e82649..369fda56b89 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ListItem/index.tsx @@ -39,10 +39,9 @@ export function ListItem(props: TokenListItemProps): ReactNode { }, [enabled]) const toggle = (): void => { - toggleList(list, enabled) - setIsActive((state) => !state) - const newState = !enabled + toggleList(list, newState) + setIsActive(newState) cowAnalytics.sendEvent({ category: CowSwapAnalyticsCategory.LIST, action: `List ${newState ? 'Enabled' : 'Disabled'}`, From 06fe6e260938f43fd7c19642e995957ea8dfa97c Mon Sep 17 00:00:00 2001 From: limitofzero Date: Thu, 8 Jan 2026 23:29:57 +0400 Subject: [PATCH 25/25] fix: handle reject for consent to import token from url --- .../modules/rwa/containers/RwaConsentModalContainer/index.tsx | 3 ++- .../src/modules/rwa/state/rwaConsentModalStateAtom.ts | 1 + .../trade/containers/TradeWidget/TradeWidgetModals.tsx | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx b/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx index f303719fdf2..52826457043 100644 --- a/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/rwa/containers/RwaConsentModalContainer/index.tsx @@ -24,7 +24,8 @@ export function RwaConsentModalContainer(): ReactNode { const onDismiss = useCallback(() => { closeModal() - }, [closeModal]) + context?.onDismiss?.() + }, [closeModal, context]) const onConfirm = useCallback(() => { if (!account || !context || !consentKey) { diff --git a/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts b/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts index 52b211142b1..e95b43dca00 100644 --- a/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts +++ b/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts @@ -8,6 +8,7 @@ export interface RwaConsentModalContext { token?: TokenWithLogo pendingImportTokens?: TokenWithLogo[] onImportSuccess?: () => void + onDismiss?: () => void } export interface RwaConsentModalState { diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx index 09b810e3e6c..134e1a712f0 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx @@ -138,6 +138,10 @@ export function TradeWidgetModals({ importTokenCallback(tokensToImport) closeAutoImportModal() }, + onDismiss: () => { + // If consent is rejected, close the auto-import modal too + closeAutoImportModal() + }, }) } }, [