diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 1b8114080ba..56ce750e635 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -2819,6 +2819,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" @@ -3594,7 +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.>>>>>>> Stashed changes>>>>>>> Stashed changes" #: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx msgid "No signatures yet" @@ -3624,6 +3633,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" @@ -4305,6 +4318,10 @@ msgstr "before that time." msgid "View transaction" msgstr "View transaction" +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +msgid "This list requires consent before importing." +msgstr "This list requires consent before importing." + #: apps/cowswap-frontend/src/modules/erc20Approve/containers/ActiveOrdersWithAffectedPermit/ActiveOrdersWithAffectedPermit.tsx msgid "is" msgstr "is" 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..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,7 +31,8 @@ import { ProgressBarExecutingOrdersUpdater, } from 'modules/orderProgressBar' import { OrdersNotificationsUpdater } from 'modules/orders' -import { useSourceChainId } from 'modules/tokensList' +import { GeoDataUpdater } from 'modules/rwa' +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 +116,8 @@ export function Updaters(): ReactNode { bridgeNetworkInfo={bridgeNetworkInfo?.data} /> + + 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..52826457043 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() @@ -28,7 +24,8 @@ export function RwaConsentModalContainer(): ReactNode { const onDismiss = useCallback(() => { closeModal() - }, [closeModal]) + context?.onDismiss?.() + }, [closeModal, context]) const onConfirm = useCallback(() => { if (!account || !context || !consentKey) { @@ -37,7 +34,14 @@ 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) { 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/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/rwa/index.ts b/apps/cowswap-frontend/src/modules/rwa/index.ts index 1a34c451843..d53031811fa 100644 --- a/apps/cowswap-frontend/src/modules/rwa/index.ts +++ b/apps/cowswap-frontend/src/modules/rwa/index.ts @@ -5,6 +5,8 @@ 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' +export * from './updaters/GeoDataUpdater' 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/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/state/rwaConsentModalStateAtom.ts b/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts index 56e96228285..e95b43dca00 100644 --- a/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts +++ b/apps/cowswap-frontend/src/modules/rwa/state/rwaConsentModalStateAtom.ts @@ -3,12 +3,17 @@ 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 + onDismiss?: () => void +} + export interface RwaConsentModalState { isModalOpen: boolean - context?: { - consentHash: string - token?: TokenWithLogo - } + context?: RwaConsentModalContext } const initialRwaConsentModalState: RwaConsentModalState = { 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 +} 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..f0bb19e8557 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx @@ -1,16 +1,26 @@ -import { useMemo } from 'react' +import { ReactNode, useMemo } from 'react' import { useCowAnalytics } from '@cowprotocol/analytics' -import { ListSearchResponse, ListState, useListsEnabledState, useRemoveList, useToggleList } from '@cowprotocol/tokens' +import { + ListSearchResponse, + ListState, + useFilterBlockedLists, + useIsListBlocked, + useListsEnabledState, + useRemoveList, +} 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' import { useAddListImport } from '../../hooks/useAddListImport' +import { useConsentAwareToggleList } from '../../hooks/useConsentAwareToggleList' import { ImportTokenListItem } from '../../pure/ImportTokenListItem' import { ListItem } from '../../pure/ListItem' @@ -26,15 +36,19 @@ export interface ManageListsProps { isListUrlValid?: boolean } -// 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) { +export function ManageLists(props: ManageListsProps): ReactNode { const { lists, listSearchResponse, isListUrlValid } = props + const country = useGeoCountry() + + // 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() const addListImport = useAddListImport() const cowAnalytics = useCowAnalytics() + const toggleList = useConsentAwareToggleList() const removeList = useRemoveList((source) => { cowAnalytics.sendEvent({ @@ -44,15 +58,8 @@ export function ManageLists(props: ManageListsProps) { }) }) - const toggleList = useToggleList((enable, source) => { - cowAnalytics.sendEvent({ - category: CowSwapAnalyticsCategory.LIST, - action: `${enable ? 'Enable' : 'Disable'} List`, - label: source, - }) - }) - const { source, listToImport, loading } = useListSearchResponse(listSearchResponse) + const { isBlocked } = useIsListBlocked(listToImport?.source, country) return ( @@ -71,6 +78,7 @@ export function ManageLists(props: ManageListsProps) { )} - {lists + {filteredLists .sort((a, b) => (a.priority || 0) - (b.priority || 0)) .map((list) => ( { updateSelectTokenWidget({ selectedPoolAddress }) @@ -140,11 +154,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,14 +194,22 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok onDismiss={onDismiss} onBack={resetTokenImport} onImport={importTokenAndClose} + isImportDisabled={isImportDisabled} + blockReason={blockReason} /> ) } if (listToImport && !standalone) { + // only show consent message when wallet is connected and consent is required + const listBlockReason = + account && requiresConsent ? t`This list requires consent before importing.` : 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 sourceKey = getSourceAsKey(listToImport.source) + const consentHash = restrictedLists.consentHashPerList[sourceKey] + + // 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 - 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: () => { + updateSelectTokenWidget({ listToImport }) + }, }) }, - [updateSelectTokenWidget], + [account, updateSelectTokenWidget, openRwaConsentModal, restrictedLists, consentCache, geoStatus], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts index 4ee2efb41bf..cf9f7957882 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts @@ -1,18 +1,72 @@ +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 - 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, + pendingImportTokens: [tokenToImport], + onImportSuccess: () => { + updateSelectTokenWidget({ tokenToImport }) + }, }) }, - [updateSelectTokenWidget], + [account, updateSelectTokenWidget, openRwaConsentModal, restrictedList, consentCache, geoStatus], ) } 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..e86d35a09c2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts @@ -0,0 +1,79 @@ +import { useAtomValue } from 'jotai' +import { useCallback } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' +import { getSourceAsKey, ListState, 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' + +// wrap toggle list functionality with consent checking +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) => { + // 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] + + if (consentHash) { + // list is restricted - if no wallet, allow toggle (consent check deferred to trade time) + if (!account) { + baseToggleList(list, enabled) + return + } + + // 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: () => { + // 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], + ) +} 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..cab77f49f53 --- /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 { getSourceAsKey, ListState, 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 sourceKey = getSourceAsKey(list.source) + const consentHash = restrictedLists.consentHashPerList[sourceKey] + + 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..af10296d84b --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useIsListRequiresConsent.ts @@ -0,0 +1,62 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { getSourceAsKey, restrictedListsAtom } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa' + +export interface ListConsentResult { + requiresConsent: boolean + consentHash: string | null + isLoading: boolean +} + +// 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() + 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 sourceKey = getSourceAsKey(listSource) + const consentHash = restrictedLists.consentHashPerList[sourceKey] + + // 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 no wallet connected, don't block - the consent modal will handle wallet connection + if (!account) { + return { requiresConsent: false, 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/hooks/useRestrictedTokenImportStatus.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts new file mode 100644 index 00000000000..799cefbc575 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { getCountryAsKey, RestrictedTokenInfo, useRestrictedToken } from '@cowprotocol/tokens' + +import { t } from '@lingui/core/macro' + +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 +} + +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 restrictedInfo = useRestrictedToken(token) + + return useMemo(() => { + // 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 + if (geoStatus.country) { + const countryKey = getCountryAsKey(geoStatus.country) + const blockedCountries = new Set(restrictedInfo.restrictedCountries) + + if (blockedCountries.has(countryKey)) { + return { + status: RestrictedTokenImportStatus.Blocked, + restrictedInfo, + isImportDisabled: true, + blockReason: t`This token is not available in your region.`, + } + } + } + + return NOT_RESTRICTED_RESULT + }, [geoStatus, restrictedInfo]) +} 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 c38c9b46b97..544088469e6 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' @@ -11,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/tokensList/pure/ImportListModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx index e540507001a..3e03e85ee0d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx @@ -5,11 +5,14 @@ import { ButtonPrimary, ModalHeader } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' +import { AlertCircle } from 'react-feather' import * as styledEl from './styled' export interface ImportListModalProps { list: ListState + isBlocked?: boolean + blockReason?: string onImport(list: ListState): void @@ -19,7 +22,8 @@ export interface ImportListModalProps { } export function ImportListModal(props: ImportListModalProps): ReactNode { - const { list, onBack, onDismiss, onImport } = 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) @@ -41,26 +45,35 @@ export function ImportListModal(props: ImportListModalProps): ReactNode { - 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 ? ( + + + {blockReason || defaultBlockReason} + + ) : ( + <> + 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..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,7 +1,9 @@ +import { ReactElement } from 'react' + 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,24 +13,29 @@ import { TokenListDetails } from '../TokenListDetails' 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 } = props +export function ImportTokenListItem(props: ImportTokenListItemProps): ReactElement { + const { list, source, importList, isBlocked, blockReason } = props return ( - {source === 'existing' ? ( + {source === 'existing' && !isBlocked ? ( Loaded + ) : isBlocked ? ( + + + {blockReason || 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..6e3b0e3c1a9 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,18 @@ export const LoadedInfo = styled.div` gap: 10px; align-items: center; ` + +export const BlockedInfo = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + align-items: flex-start; + color: var(${UI.COLOR_DANGER_TEXT}); + font-size: 13px; + + > svg { + flex-shrink: 0; + width: 16px; + height: 16px; + } +` 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..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' @@ -16,6 +18,8 @@ const ExternalLinkStyled = styled(ExternalLink)` export interface ImportTokenModalProps { tokens: TokenWithLogo[] + isImportDisabled?: boolean + blockReason?: string | null onBack?(): void @@ -24,10 +28,8 @@ 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) { - const { tokens, onBack, onDismiss, onImport } = props +export function ImportTokenModal(props: ImportTokenModalProps): ReactElement { + 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/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'}`, 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..acaf9bffe42 --- /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, getCountryAsKey, restrictedListsAtom } from '@cowprotocol/tokens' + +import { useGeoStatus } from 'modules/rwa' + +/** + * update the blockedListSourcesAtom based on geo-blocking only: + * - only blocks lists when country is known and the list is blocked for that country + * - does not block when country is unknown (consent check happens at trade/import time) + */ +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 countryKey = getCountryAsKey(geoStatus.country) + + for (const [sourceKey, blockedCountries] of Object.entries(restrictedLists.blockedCountriesPerList)) { + if (blockedCountries.includes(countryKey)) { + blockedSources.add(sourceKey) + } + } + } + + setBlockedListSources(blockedSources) + }, [geoStatus, restrictedLists, setBlockedListSources]) + + return null +} 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..134e1a712f0 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,38 @@ 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() + }, + onDismiss: () => { + // If consent is rejected, close the auto-import modal too + closeAutoImportModal() + }, + }) + } + }, [ + isAutoImportModalOpen, + requiresConsent, + restrictedTokenInfo, + tokenNeedingConsent, + tokensToImport, + openRwaConsentModal, + importTokenCallback, + closeAutoImportModal, + ]) + if (genericModal) { return genericModal } @@ -139,8 +175,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) { 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..4282ea2f707 --- /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 { getSourceAsKey } 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: { + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + }, + consentHashPerList: { + [getSourceAsKey(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: { + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US'], + [getSourceAsKey(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/useFilterBlockedLists.ts b/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts new file mode 100644 index 00000000000..ed829f6f1ea --- /dev/null +++ b/libs/tokens/src/hooks/lists/useFilterBlockedLists.ts @@ -0,0 +1,32 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { getCountryAsKey, getSourceAsKey } from './useIsListBlocked' + +import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' +import { ListState } from '../../types' + +/** + * filter out token lists that are blocked for the given country + */ +export function useFilterBlockedLists(lists: ListState[], country: string | null): ListState[] { + const restrictedLists = useAtomValue(restrictedListsAtom) + + return useMemo(() => { + if (!country || !restrictedLists.isLoaded) { + return lists + } + + const countryKey = getCountryAsKey(country) + + return lists.filter((list) => { + const blockedCountries = restrictedLists.blockedCountriesPerList[getSourceAsKey(list.source)] + + if (!blockedCountries) { + return true + } + + return !blockedCountries.includes(countryKey) + }) + }, [lists, country, restrictedLists]) +} 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..1c6c997bb65 --- /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 { getSourceAsKey, 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: { + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + }, + consentHashPerList: { + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: 'bafkreidcn4bhj44nnethx6clfspkapahshqyq44adz674y7je5wyfiazsq', + }, + isLoaded: true, +} + +describe('getSourceAsKey', () => { + it('converts to lowercase', () => { + expect(getSourceAsKey('HTTPS://EXAMPLE.COM/list.json')).toBe('https://example.com/list.json') + }) + + it('trims whitespace', () => { + expect(getSourceAsKey(' https://example.com/list.json ')).toBe('https://example.com/list.json') + }) + + it('handles mixed case and whitespace', () => { + expect(getSourceAsKey(' 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(false) // returns early when listSource is undefined + }) + + 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/useIsListBlocked.ts b/libs/tokens/src/hooks/lists/useIsListBlocked.ts new file mode 100644 index 00000000000..3577ea738b0 --- /dev/null +++ b/libs/tokens/src/hooks/lists/useIsListBlocked.ts @@ -0,0 +1,43 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { restrictedListsAtom } from '../../state/restrictedTokens/restrictedTokensAtom' + +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 +} + +/** + * 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[getSourceAsKey(listSource)] + + if (!blockedCountries) { + return { isBlocked: false, isLoading: false } + } + + const isBlocked = blockedCountries.includes(getCountryAsKey(country)) + return { isBlocked, isLoading: false } + }, [listSource, country, restrictedLists]) +} 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..3d87a3e75da --- /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 { getSourceAsKey } 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: { + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US', 'CN'], + }, + consentHashPerList: { + [getSourceAsKey(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(getSourceAsKey(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: { + [getSourceAsKey(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: { + [getSourceAsKey(MOCK_ONDO_LIST_URL)]: ['US'], + }, + consentHashPerList: {}, + isLoaded: true, + } + + const { result } = renderHook(() => useRestrictedListInfo(MOCK_ONDO_LIST_URL), { + wrapper: createWrapper(incompleteState), + }) + + expect(result.current).toBeNull() + }) +}) diff --git a/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts b/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts new file mode 100644 index 00000000000..00b4cab2d09 --- /dev/null +++ b/libs/tokens/src/hooks/lists/useRestrictedListInfo.ts @@ -0,0 +1,36 @@ +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' + +import { getSourceAsKey } 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 sourceKey = getSourceAsKey(listSource) + const blockedCountries = restrictedLists.blockedCountriesPerList[sourceKey] + const consentHash = restrictedLists.consentHashPerList[sourceKey] + + if (!blockedCountries || !consentHash) { + return null + } + + return { + source: sourceKey, + blockedCountries, + consentHash, + } + }, [listSource, restrictedLists]) +} 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..33917c9b56d 100644 --- a/libs/tokens/src/index.ts +++ b/libs/tokens/src/index.ts @@ -56,7 +56,9 @@ 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, restrictedListsAtom } from './state/restrictedTokens/restrictedTokensAtom' +export { blockedListSourcesAtom } from './state/tokens/blockedListSourcesAtom' // Utils export { getTokenId } from './state/restrictedTokens/restrictedTokensAtom' @@ -71,4 +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, getSourceAsKey, getCountryAsKey } 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 be4166f64a5..674db3b6a24 100644 --- a/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts +++ b/libs/tokens/src/state/restrictedTokens/restrictedTokensAtom.ts @@ -42,3 +42,21 @@ export const restrictedTokensLastUpdateAtom = atomWithStorage( getJotaiMergerStorage(), { unstable_getOnInit: true }, ) + +/** + * 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, +} + +export const restrictedListsAtom = atom(initialRestrictedListsState) diff --git a/libs/tokens/src/state/tokens/allTokensAtom.ts b/libs/tokens/src/state/tokens/allTokensAtom.ts index f7fa53c8e1b..b108f6e881e 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 { getSourceAsKey } 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 sourceKey = getSourceAsKey(list.source) + if (blockedListSources.has(sourceKey)) { + 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..d5fc4921d06 --- /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 37e46b17758..570a3aefb05 100644 --- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx +++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx @@ -1,17 +1,24 @@ +import { useAtomValue, useSetAtom } from 'jotai' import { useEffect } from 'react' import { getRestrictedTokenLists } from '@cowprotocol/core' import { TokenInfo } from '@cowprotocol/types' +import { getSourceAsKey } from '../../hooks/lists/useIsListBlocked' 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 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 @@ -74,7 +81,12 @@ async function fetchTokenList(url: string, retries = MAX_RETRIES): Promise { if (!isRwaGeoblockEnabled) { @@ -92,9 +104,15 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted const tokensMap: Record = {} const countriesPerToken: Record = {} const consentHashPerToken: Record = {} + const blockedCountriesPerList: Record = {} + const consentHashPerList: Record = {} await Promise.all( restrictedLists.map(async (list) => { + const urlKey = getSourceAsKey(list.tokenListUrl) + blockedCountriesPerList[urlKey] = list.restrictedCountries + consentHashPerList[urlKey] = RWA_CONSENT_HASH + try { const tokens = await fetchTokenList(list.tokenListUrl) @@ -102,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) @@ -117,6 +135,12 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted isLoaded: true, } + setRestrictedLists({ + blockedCountriesPerList, + consentHashPerList, + isLoaded: true, + }) + saveToCache(listState) } catch (error) { console.error('Error loading restricted tokens:', error) @@ -124,7 +148,7 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted } loadRestrictedTokens() - }, [isRwaGeoblockEnabled, shouldFetch, saveToCache]) + }, [isRwaGeoblockEnabled, shouldFetch, saveToCache, setRestrictedLists]) return null }