diff --git a/src/core/resources/search/parseTokenSearch.ts b/src/core/resources/search/parseTokenSearch.ts new file mode 100644 index 0000000000..271ca9cd76 --- /dev/null +++ b/src/core/resources/search/parseTokenSearch.ts @@ -0,0 +1,21 @@ +import { AddressOrEth } from '~/core/types/assets'; +import { ChainId } from '~/core/types/chains'; +import { SearchAsset } from '~/core/types/search'; +import { isNativeAsset } from '~/core/utils/chains'; + +export function parseTokenSearch( + asset: SearchAsset, + chainId: ChainId, +): SearchAsset { + const networkInfo = asset.networks[chainId]; + + return { + ...asset, + address: networkInfo ? networkInfo.address : asset.address, + chainId, + decimals: networkInfo ? networkInfo.decimals : asset.decimals, + isNativeAsset: isNativeAsset(asset.address, chainId), + mainnetAddress: asset.uniqueId as AddressOrEth, + uniqueId: `${networkInfo?.address || asset.uniqueId}_${chainId}`, + }; +} diff --git a/src/core/resources/search/swappableAddresses.ts b/src/core/resources/search/swappableAddresses.ts deleted file mode 100644 index 7ddb07f71d..0000000000 --- a/src/core/resources/search/swappableAddresses.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { tokenSearchHttp } from '~/core/network/tokenSearch'; -import { - QueryConfig, - QueryFunctionArgs, - QueryFunctionResult, - createQueryKey, - queryClient, -} from '~/core/react-query'; -import { AddressOrEth } from '~/core/types/assets'; - -// /////////////////////////////////////////////// -// Query Types - -export type SwappableAddressesArgs = { - addresses: AddressOrEth[]; - fromChainId: number; - toChainId?: number; -}; - -// /////////////////////////////////////////////// -// Query Key - -const swappableAddressesQueryKey = ({ - addresses, - fromChainId, - toChainId, -}: SwappableAddressesArgs) => - createQueryKey( - 'SwappableAddresses', - { addresses, fromChainId, toChainId }, - { persisterVersion: 1 }, - ); - -type SwappableAddressesQueryKey = ReturnType; - -// /////////////////////////////////////////////// -// Query Function - -async function swappableAddressesQueryFunction({ - queryKey: [{ addresses, fromChainId, toChainId }], -}: QueryFunctionArgs) { - const filteredAddresses = await tokenSearchHttp.post<{ - data?: AddressOrEth[]; - }>(`/${fromChainId}`, { - addresses, - toChainId, - }); - return filteredAddresses.data.data || []; -} - -type SwappableAddressesResult = QueryFunctionResult< - typeof swappableAddressesQueryFunction ->; - -// /////////////////////////////////////////////// -// Query Fetcher - -export async function fetchSwappableAddresses( - { addresses, fromChainId, toChainId }: SwappableAddressesArgs, - config: QueryConfig< - SwappableAddressesResult, - Error, - SwappableAddressesResult, - SwappableAddressesQueryKey - > = {}, -) { - return await queryClient.fetchQuery({ - queryKey: swappableAddressesQueryKey({ - addresses, - fromChainId, - toChainId, - }), - queryFn: swappableAddressesQueryFunction, - ...config, - }); -} - -// /////////////////////////////////////////////// -// Query Hook - -export function useSwappableAddresses( - { addresses, fromChainId, toChainId }: SwappableAddressesArgs, - config: QueryConfig< - SwappableAddressesResult, - Error, - TSelectResult, - SwappableAddressesQueryKey - > = {}, -) { - return useQuery({ - queryKey: swappableAddressesQueryKey({ - addresses, - fromChainId, - toChainId, - }), - queryFn: swappableAddressesQueryFunction, - ...config, - }); -} diff --git a/src/core/resources/search/tokenDiscovery.ts b/src/core/resources/search/tokenDiscovery.ts new file mode 100644 index 0000000000..42c1219091 --- /dev/null +++ b/src/core/resources/search/tokenDiscovery.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query'; + +import { createHttpClient } from '~/core/network/internal/createHttpClient'; +import { QueryFunctionArgs, createQueryKey } from '~/core/react-query'; +import { ChainId } from '~/core/types/chains'; +import { SearchAsset } from '~/core/types/search'; + +import { parseTokenSearch } from './parseTokenSearch'; + +const tokenSearchDiscoveryHttp = createHttpClient({ + baseUrl: 'https://token-search.rainbow.me/v3/discovery', + timeout: 30000, +}); + +type TokenDiscoveryArgs = { + chainId: ChainId; +}; + +const tokenDiscoveryQueryKey = ({ chainId }: TokenDiscoveryArgs) => + createQueryKey('TokenDiscovery', { chainId }, { persisterVersion: 1 }); + +async function tokenSearchQueryFunction({ + queryKey: [{ chainId }], +}: QueryFunctionArgs) { + const url = `/${chainId}`; + + try { + const tokenSearch = await tokenSearchDiscoveryHttp.get<{ + data: SearchAsset[]; + }>(url); + return tokenSearch.data.data.map((asset) => + parseTokenSearch(asset, chainId), + ); + } catch (e) { + return []; + } +} + +export function useTokenDiscovery({ chainId }: TokenDiscoveryArgs) { + return useQuery({ + queryKey: tokenDiscoveryQueryKey({ chainId }), + queryFn: tokenSearchQueryFunction, + staleTime: 15 * 60 * 1000, // 15 min + gcTime: 24 * 60 * 60 * 1000, // 1 day + select: (data) => data.slice(0, 3), + }); +} diff --git a/src/core/resources/search/tokenSearch.ts b/src/core/resources/search/tokenSearch.ts index 67f09dbef2..42743502f6 100644 --- a/src/core/resources/search/tokenSearch.ts +++ b/src/core/resources/search/tokenSearch.ts @@ -1,7 +1,6 @@ import { isAddress } from '@ethersproject/address'; import { useQueries, useQuery } from '@tanstack/react-query'; import qs from 'qs'; -import { Address } from 'viem'; import { tokenSearchHttp } from '~/core/network/tokenSearch'; import { @@ -11,11 +10,6 @@ import { createQueryKey, queryClient, } from '~/core/react-query'; -import { - BNB_BSC_ADDRESS, - ETH_ADDRESS, - POL_POLYGON_ADDRESS, -} from '~/core/references'; import { ChainId } from '~/core/types/chains'; import { SearchAsset, @@ -25,6 +19,8 @@ import { } from '~/core/types/search'; import { getSupportedChains, isCustomChain } from '~/core/utils/chains'; +import { parseTokenSearch } from './parseTokenSearch'; + // /////////////////////////////////////////////// // Query Types @@ -90,43 +86,14 @@ async function tokenSearchQueryFunction({ const url = `/${chainId}/?${qs.stringify(queryParams)}`; try { const tokenSearch = await tokenSearchHttp.get<{ data: SearchAsset[] }>(url); - return parseTokenSearch(tokenSearch.data.data, chainId); + return tokenSearch.data.data.map((asset) => + parseTokenSearch(asset, chainId), + ); } catch (e) { return []; } } -function parseTokenSearch(assets: SearchAsset[], chainId: ChainId) { - return assets - .map((a) => { - const networkInfo = a.networks[chainId]; - - const asset: SearchAsset = { - ...a, - address: networkInfo ? networkInfo.address : a.address, - chainId, - decimals: networkInfo ? networkInfo.decimals : a.decimals, - isNativeAsset: [ - `${ETH_ADDRESS}_${ChainId.mainnet}`, - `${ETH_ADDRESS}_${ChainId.optimism}`, - `${ETH_ADDRESS}_${ChainId.arbitrum}`, - `${BNB_BSC_ADDRESS}_${ChainId.bsc}`, - `${POL_POLYGON_ADDRESS}_${ChainId.polygon}`, - `${ETH_ADDRESS}_${ChainId.base}`, - `${ETH_ADDRESS}_${ChainId.zora}`, - `${ETH_ADDRESS}_${ChainId.avalanche}`, - `${ETH_ADDRESS}_${ChainId.blast}`, - `${ETH_ADDRESS}_${ChainId.degen}`, - ].includes(`${a.uniqueId}_${chainId}`), - mainnetAddress: a.uniqueId as Address, - uniqueId: `${networkInfo?.address || a.uniqueId}_${chainId}`, - }; - - return asset; - }) - .filter(Boolean); -} - type TokenSearchResult = QueryFunctionResult; // /////////////////////////////////////////////// diff --git a/src/core/state/favorites/index.ts b/src/core/state/favorites/index.ts index 4afd590b2c..dd3b84bf5d 100644 --- a/src/core/state/favorites/index.ts +++ b/src/core/state/favorites/index.ts @@ -17,8 +17,8 @@ import { ETH_BLAST_ADDRESS, ETH_OPTIMISM_ADDRESS, ETH_ZORA_ADDRESS, - POL_POLYGON_ADDRESS, OP_ADDRESS, + POL_POLYGON_ADDRESS, SOCKS_ADDRESS, SOCKS_ARBITRUM_ADDRESS, USDB_BLAST_ADDRESS, diff --git a/src/entries/popup/hooks/useSearchCurrencyLists.ts b/src/entries/popup/hooks/useSearchCurrencyLists.ts index ad0910195d..515820f4db 100644 --- a/src/entries/popup/hooks/useSearchCurrencyLists.ts +++ b/src/entries/popup/hooks/useSearchCurrencyLists.ts @@ -7,6 +7,7 @@ import { Address } from 'viem'; import { SUPPORTED_CHAINS } from '~/core/references/chains'; import { useAssetSearchMetadataAllNetworks } from '~/core/resources/assets/assetMetadata'; import { useTokenSearch } from '~/core/resources/search'; +import { useTokenDiscovery } from '~/core/resources/search/tokenDiscovery'; import { useTokenSearchAllNetworks } from '~/core/resources/search/tokenSearch'; import { useTestnetModeStore } from '~/core/state/currentSettings/testnetMode'; import { ParsedSearchAsset } from '~/core/types/assets'; @@ -43,7 +44,8 @@ export type AssetToBuySectionId = | 'favorites' | 'verified' | 'unverified' - | 'other_networks'; + | 'other_networks' + | 'popular'; export interface AssetToBuySection { data: SearchAsset[]; @@ -66,6 +68,21 @@ const filterBridgeAsset = ({ asset?.name?.toLowerCase()?.startsWith(filter?.toLowerCase()) || asset?.symbol?.toLowerCase()?.startsWith(filter?.toLowerCase()); +/** + * @returns a new array of `assets` that don't overlap with `others` + */ +function difference( + assets: SearchAsset[], + others: (SearchAsset | undefined | null)[], +) { + const _others = others.filter(Boolean); + return assets.filter((asset) => { + return !_others.some((other) => + isLowerCaseMatch(other.uniqueId, asset.uniqueId), + ); + }); +} + export function useSearchCurrencyLists({ assetToSell, inputChainId, @@ -279,6 +296,10 @@ export function useSearchCurrencyLists({ }, ); + const { data: popularAssets = [] } = useTokenDiscovery({ + chainId: outputChainId, + }); + const { favorites } = useFavoriteAssets(); const favoritesList = useMemo(() => { @@ -468,38 +489,6 @@ export function useSearchCurrencyLists({ .flat() .filter(Boolean); - const filterAssetsFromBridgeAndAssetToSell = useCallback( - (assets?: SearchAsset[]) => - assets?.filter( - (curatedAsset) => - !isLowerCaseMatch(curatedAsset?.address, bridgeAsset?.address) && - !isLowerCaseMatch(curatedAsset?.address, assetToSell?.address), - ) || [], - [assetToSell?.address, bridgeAsset?.address], - ); - - const filterAssetsFromFavoritesBridgeAndAssetToSell = useCallback( - (assets?: SearchAsset[]) => - filterAssetsFromBridgeAndAssetToSell(assets)?.filter( - (curatedAsset) => - !favoritesList - ?.map((fav) => fav.address) - .includes(curatedAsset.address), - ) || [], - [favoritesList, filterAssetsFromBridgeAndAssetToSell], - ); - - const filterAssetsFromVerifiedAssets = useCallback( - (assets?: SearchAsset[]) => - assets?.filter( - (asset) => - !targetAllNetworksVerifiedAssets.some((verifiedAsset) => - isLowerCaseMatch(verifiedAsset.uniqueId, asset.uniqueId), - ), - ) ?? [], - [targetAllNetworksVerifiedAssets], - ); - // the lists below should be filtered by favorite/bridge asset match const results = useMemo(() => { const sections: AssetToBuySection[] = []; @@ -508,6 +497,10 @@ export function useSearchCurrencyLists({ return sections; } + if (popularAssets?.length) { + sections.push({ id: 'popular', data: popularAssets }); + } + if (bridgeAsset) { sections.push({ data: [bridgeAsset], @@ -516,15 +509,27 @@ export function useSearchCurrencyLists({ } if (favoritesList?.length) { sections.push({ - data: filterAssetsFromBridgeAndAssetToSell(favoritesList), + data: difference(favoritesList, [ + ...popularAssets, + bridgeAsset, + assetToSell, + ]), id: 'favorites', }); } + const otherSectionsAssets = [ + ...popularAssets, + ...favoritesList, + bridgeAsset, + assetToSell, + ]; + if (query === '') { sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell( - curatedAssets[outputChainId], + data: difference( + curatedAssets[outputChainId] || [], + otherSectionsAssets, ), id: 'verified', }); @@ -533,8 +538,9 @@ export function useSearchCurrencyLists({ if (hasVerifiedAssets) { sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell( + data: difference( targetAllNetworksVerifiedAssets, + otherSectionsAssets, ), id: 'verified', }); @@ -545,7 +551,7 @@ export function useSearchCurrencyLists({ targetAllNetworkMetadataAssets.length > 0; if (hasSomeUnverifiedAssets) { - let allUnverifiedAssets = filterAssetsFromFavoritesBridgeAndAssetToSell( + let allUnverifiedAssets = difference( uniqBy( [ ...targetAllNetworksUnverifiedAssets, @@ -553,11 +559,14 @@ export function useSearchCurrencyLists({ ], 'uniqueId', ), + otherSectionsAssets, ); if (hasVerifiedAssets) { - allUnverifiedAssets = - filterAssetsFromVerifiedAssets(allUnverifiedAssets); + allUnverifiedAssets = difference( + allUnverifiedAssets, + targetAllNetworksVerifiedAssets, + ); } sections.push({ @@ -570,27 +579,21 @@ export function useSearchCurrencyLists({ } else { if (targetVerifiedAssets?.length) { sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell( - targetVerifiedAssets, - ), + data: difference(targetVerifiedAssets, otherSectionsAssets), id: 'verified', }); } if (targetUnverifiedAssets?.length && enableUnverifiedSearch) { sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell( - targetUnverifiedAssets, - ), + data: difference(targetUnverifiedAssets, otherSectionsAssets), id: 'unverified', }); } if (!sections.length && crosschainExactMatches?.length) { sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell( - crosschainExactMatches, - ), + data: difference(crosschainExactMatches, otherSectionsAssets), id: 'other_networks', }); } @@ -599,19 +602,18 @@ export function useSearchCurrencyLists({ return sections; }, [ bridge, + popularAssets, bridgeAsset, favoritesList, + assetToSell, query, enableAllNetworkTokenSearch, bridgeList, - filterAssetsFromBridgeAndAssetToSell, - filterAssetsFromFavoritesBridgeAndAssetToSell, curatedAssets, outputChainId, targetAllNetworksVerifiedAssets, targetAllNetworksUnverifiedAssets, targetAllNetworkMetadataAssets, - filterAssetsFromVerifiedAssets, targetVerifiedAssets, targetUnverifiedAssets, enableUnverifiedSearch, diff --git a/src/entries/popup/hooks/useSwappableAssets.ts b/src/entries/popup/hooks/useSwappableAssets.ts deleted file mode 100644 index 903100c016..0000000000 --- a/src/entries/popup/hooks/useSwappableAssets.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { useMemo } from 'react'; - -import { - selectUserAssetAddressMapByChainId, - selectUserAssetsList, -} from '~/core/resources/_selectors/assets'; -import { useUserAssets } from '~/core/resources/assets'; -import { useSwappableAddresses } from '~/core/resources/search/swappableAddresses'; -import { useCurrentAddressStore, useCurrentCurrencyStore } from '~/core/state'; -import { ParsedAssetsDictByChain, ParsedUserAsset } from '~/core/types/assets'; -import { ChainId } from '~/core/types/chains'; - -export function useSwappableAssets(toChainId?: ChainId) { - const { currentAddress: address } = useCurrentAddressStore(); - - const { currentCurrency: currency } = useCurrentCurrencyStore(); - - const { data: userAssets } = useUserAssets({ - address, - currency, - }); - - const fullUserAssetList = selectUserAssetsList( - userAssets || ({} as ParsedAssetsDictByChain), - ); - const assetAddressMap = selectUserAssetAddressMapByChainId( - userAssets || ({} as ParsedAssetsDictByChain), - ); - - const { - data: swappableMainnetAddresses, - isLoading: swappableMainnetAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.mainnet], - fromChainId: ChainId.mainnet, - toChainId, - }); - - const { - data: swappableOptimismAddresses, - isLoading: swappableOptimismAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.optimism], - fromChainId: ChainId.optimism, - toChainId, - }); - - const { - data: swappableBaseAddresses, - isLoading: swappableBaseAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.base], - fromChainId: ChainId.base, - toChainId, - }); - - const { - data: swappableZoraAddresses, - isLoading: swappableZoraAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.zora], - fromChainId: ChainId.zora, - toChainId, - }); - - const { - data: swappableBscAddresses, - isLoading: swappableBscAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.bsc], - fromChainId: ChainId.bsc, - toChainId, - }); - - const { - data: swappablePolygonAddresses, - isLoading: swappablePolygonAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.polygon], - fromChainId: ChainId.polygon, - toChainId, - }); - - const { - data: swappableArbitrumAddresses, - isLoading: swappableArbitrumAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.arbitrum], - fromChainId: ChainId.arbitrum, - toChainId, - }); - - const { - data: swappableAvalancheAddresses, - isLoading: swappableAvalancheAddressesAreLoading, - } = useSwappableAddresses({ - addresses: assetAddressMap[ChainId.avalanche], - fromChainId: ChainId.avalanche, - toChainId, - }); - - const swappableInfo = useMemo( - () => ({ - [ChainId.mainnet]: { - addresses: swappableMainnetAddresses, - loading: swappableMainnetAddressesAreLoading, - }, - [ChainId.optimism]: { - addresses: swappableOptimismAddresses, - loading: swappableOptimismAddressesAreLoading, - }, - [ChainId.bsc]: { - addresses: swappableBscAddresses, - loading: swappableBscAddressesAreLoading, - }, - [ChainId.polygon]: { - addresses: swappablePolygonAddresses, - loading: swappablePolygonAddressesAreLoading, - }, - [ChainId.arbitrum]: { - addresses: swappableArbitrumAddresses, - loading: swappableArbitrumAddressesAreLoading, - }, - [ChainId.base]: { - addresses: swappableBaseAddresses, - loading: swappableBaseAddressesAreLoading, - }, - [ChainId.zora]: { - addresses: swappableZoraAddresses, - loading: swappableZoraAddressesAreLoading, - }, - [ChainId.avalanche]: { - addresses: swappableAvalancheAddresses, - loading: swappableAvalancheAddressesAreLoading, - }, - }), - [ - swappableArbitrumAddresses, - swappableArbitrumAddressesAreLoading, - swappableBscAddresses, - swappableBscAddressesAreLoading, - swappableMainnetAddresses, - swappableMainnetAddressesAreLoading, - swappableOptimismAddresses, - swappableOptimismAddressesAreLoading, - swappablePolygonAddresses, - swappablePolygonAddressesAreLoading, - swappableBaseAddresses, - swappableBaseAddressesAreLoading, - swappableZoraAddresses, - swappableZoraAddressesAreLoading, - swappableAvalancheAddresses, - swappableAvalancheAddressesAreLoading, - ], - ); - - const isSwappableAsset = (asset: ParsedUserAsset) => { - const { address, chainId } = asset; - if (chainId === toChainId) return true; - const { addresses, loading } = swappableInfo[chainId]; - return loading || addresses?.includes(address); - }; - - if (!toChainId) return fullUserAssetList; - - return fullUserAssetList.filter((asset) => isSwappableAsset(asset)); -} diff --git a/src/entries/popup/pages/swap/SwapTokenInput/TokenDropdown/TokenToBuySection.tsx b/src/entries/popup/pages/swap/SwapTokenInput/TokenDropdown/TokenToBuySection.tsx index 3feb0e5146..5e5e9dec40 100644 --- a/src/entries/popup/pages/swap/SwapTokenInput/TokenDropdown/TokenToBuySection.tsx +++ b/src/entries/popup/pages/swap/SwapTokenInput/TokenDropdown/TokenToBuySection.tsx @@ -66,6 +66,14 @@ const sectionProps: { [id in AssetToBuySectionId]: SectionProp } = { webkitBackgroundClip: undefined, background: undefined, }, + popular: { + title: i18n.t('token_search.section_header.popular'), + symbol: 'flame' as SymbolProps['symbol'], + color: 'red' as TextStyles['color'], + gradient: undefined, + webkitBackgroundClip: undefined, + background: undefined, + }, }; const bridgeSectionsColorsByChain = { @@ -138,7 +146,7 @@ export const getTokenToBuySectionElements = ({ @@ -170,7 +178,6 @@ export const getTokenToBuySectionElements = ({ assetSection.data.map((asset, i) => { return ( onSelectAsset?.(asset as ParsedSearchAsset)} testId={`${asset?.uniqueId}-${assetSection.id}-token-to-buy-row`} diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index 969105db2f..6f2a7b19fb 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1629,7 +1629,8 @@ "unverified": "Unverified", "verified": "Verified", "on_other_networks": "On other networks", - "bridge": "Bridge" + "bridge": "Bridge", + "popular": "Popular in Rainbow" }, "verified_by_rainbow": "Verified by Rainbow" },