diff --git a/src/__swaps__/screens/Swap/resources/search/search.ts b/src/__swaps__/screens/Swap/resources/search/search.ts index 52ed1d53443..8faa86a9196 100644 --- a/src/__swaps__/screens/Swap/resources/search/search.ts +++ b/src/__swaps__/screens/Swap/resources/search/search.ts @@ -4,10 +4,14 @@ import { SearchAsset, TokenSearchAssetKey, TokenSearchListId, TokenSearchThresho import { RainbowError, logger } from '@/logger'; import { RainbowFetchClient } from '@/rainbow-fetch'; import { QueryConfigWithSelect, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; -import { isAddress } from '@ethersproject/address'; +import { getAddress, isAddress } from '@ethersproject/address'; +import { getUniqueId } from '@/utils/ethereumUtils'; +import { Contract } from '@ethersproject/contracts'; import { useQuery } from '@tanstack/react-query'; import qs from 'qs'; -import { parseTokenSearch } from './utils'; +import { parseTokenSearch, parseTokenSearchAcrossNetworks } from './utils'; +import { getProvider } from '@/handlers/web3'; +import { erc20ABI } from '@/references'; const ALL_VERIFIED_TOKENS_PARAM = '/?list=verifiedAssets'; @@ -27,12 +31,16 @@ export type TokenSearchArgs = { chainId?: ChainId; fromChainId?: ChainId | ''; keys?: TokenSearchAssetKey[]; - list: TokenSearchListId; + list?: TokenSearchListId; threshold?: TokenSearchThreshold; query?: string; shouldPersist?: boolean; }; +export type TokenSearchAllNetworksArgs = { + query: string; +}; + // /////////////////////////////////////////////// // Query Key @@ -44,17 +52,64 @@ const tokenSearchQueryKey = ({ chainId, fromChainId, keys, list, threshold, quer ); }; +const tokenSearchAllNetworksQueryKey = ({ query }: TokenSearchAllNetworksArgs) => { + const shouldPersist = query === ''; + return createQueryKey('TokenSearchAllNetworks', { query }, { persisterVersion: shouldPersist ? 1 : undefined }); +}; + type TokenSearchQueryKey = ReturnType; +type TokenSearchAllNetworksQueryKey = ReturnType; + // /////////////////////////////////////////////// // Query Function +const getImportedAsset = async (searchQuery: string, chainId: number = ChainId.mainnet): Promise => { + if (isAddress(searchQuery)) { + const provider = getProvider({ chainId }); + const tokenContract = new Contract(searchQuery, erc20ABI, provider); + try { + const [name, symbol, decimals, address] = await Promise.all([ + tokenContract.name(), + tokenContract.symbol(), + tokenContract.decimals(), + getAddress(searchQuery), + ]); + const uniqueId = getUniqueId(address, chainId); + + return [ + { + chainId, + address, + decimals, + highLiquidity: false, + isRainbowCurated: false, + isVerified: false, + mainnetAddress: address, + name, + networks: { + [chainId]: { + address, + decimals, + }, + }, + symbol, + uniqueId, + } as SearchAsset, + ]; + } catch (e) { + logger.warn('[getImportedAsset]: error getting imported token data', { error: (e as Error).message }); + return []; + } + } + return []; +}; async function tokenSearchQueryFunction({ queryKey: [{ chainId, fromChainId, keys, list, threshold, query }], }: QueryFunctionArgs) { const queryParams: { keys?: string; - list: TokenSearchListId; + list?: TokenSearchListId; threshold?: TokenSearchThreshold; query?: string; fromChainId?: number | string; @@ -73,7 +128,7 @@ async function tokenSearchQueryFunction({ } const url = `${chainId ? `/${chainId}` : ''}/?${qs.stringify(queryParams)}`; - const isSearchingVerifiedAssets = queryParams.list === 'verifiedAssets'; + const isSearchingVerifiedAssets = (queryParams.list && queryParams.list === 'verifiedAssets') || !queryParams.list; try { if (isAddressSearch && isSearchingVerifiedAssets) { @@ -102,6 +157,43 @@ async function tokenSearchQueryFunction({ } } +async function tokenSearchQueryFunctionAllNetworks({ queryKey: [{ query }] }: QueryFunctionArgs) { + const queryParams: { + list?: string; + query?: string; + } = { + query, + }; + + const isAddressSearch = query && isAddress(query); + + const searchDefaultMainnetVerifiedList = query === ''; + if (searchDefaultMainnetVerifiedList) { + queryParams.list = 'verifiedAssets'; + } + + const url = `${searchDefaultMainnetVerifiedList ? `/${ChainId.mainnet}` : ''}/?${qs.stringify(queryParams)}`; + + try { + if (isAddressSearch) { + const tokenSearch = await tokenSearchHttp.get<{ data: SearchAsset[] }>(url); + + if (tokenSearch && tokenSearch.data.data.length > 0) { + return parseTokenSearch(tokenSearch.data.data); + } + + const result = await getImportedAsset(query); + return result; + } else { + const tokenSearch = await tokenSearchHttp.get<{ data: SearchAsset[] }>(url); + return parseTokenSearchAcrossNetworks(tokenSearch.data.data); + } + } catch (e) { + logger.error(new RainbowError('[tokenSearchQueryFunction]: Token search failed'), { url }); + return []; + } +} + export type TokenSearchResult = QueryFunctionResult; // /////////////////////////////////////////////// @@ -144,3 +236,13 @@ export function useTokenSearch( keepPreviousData: true, }); } + +export function useTokenSearchAllNetworks( + { query }: TokenSearchAllNetworksArgs, + config: QueryConfigWithSelect = {} +) { + return useQuery(tokenSearchAllNetworksQueryKey({ query }), tokenSearchQueryFunctionAllNetworks, { + ...config, + keepPreviousData: true, + }); +} diff --git a/src/__swaps__/screens/Swap/resources/search/utils.ts b/src/__swaps__/screens/Swap/resources/search/utils.ts index 975c0c3e8f4..f2f669e6799 100644 --- a/src/__swaps__/screens/Swap/resources/search/utils.ts +++ b/src/__swaps__/screens/Swap/resources/search/utils.ts @@ -1,3 +1,4 @@ +import { uniqBy } from 'lodash'; import { ChainId } from '@/state/backendNetworks/types'; import { SearchAsset } from '@/__swaps__/types/search'; import { Address } from 'viem'; @@ -49,3 +50,30 @@ export function parseTokenSearch(assets: SearchAsset[], chainId?: ChainId): Sear return results; } + +export function parseTokenSearchAcrossNetworks(assets: SearchAsset[]): SearchAsset[] { + const results = assets.map(asset => { + const assetNetworks = asset.networks; + const networkKeys = Object.keys(assetNetworks); + const firstNetworkChainId = Number(networkKeys[0] || asset.chainId); + + const mainnetInfo = assetNetworks[ChainId.mainnet]; + const firstNetworkInfo = assetNetworks[firstNetworkChainId]; + const chainId = mainnetInfo ? ChainId.mainnet : firstNetworkChainId; + const address = mainnetInfo ? mainnetInfo.address : firstNetworkInfo?.address || asset.address; + const decimals = mainnetInfo ? mainnetInfo.decimals : firstNetworkInfo?.decimals || asset.decimals; + const uniqueId = `${address}_${chainId}`; + + return { + ...asset, + address, + chainId, + decimals, + isNativeAsset: isNativeAsset(address, chainId), + mainnetAddress: mainnetInfo ? mainnetInfo.address : chainId === ChainId.mainnet ? address : ('' as Address), + uniqueId, + }; + }); + const uniqRes = uniqBy(results, 'address'); + return uniqRes; +} diff --git a/src/__swaps__/types/search.ts b/src/__swaps__/types/search.ts index c11d1a6f920..b51a452b481 100644 --- a/src/__swaps__/types/search.ts +++ b/src/__swaps__/types/search.ts @@ -10,6 +10,14 @@ export type TokenSearchThreshold = 'CONTAINS' | 'CASE_SENSITIVE_EQUAL'; export type TokenSearchListId = 'highLiquidityAssets' | 'lowLiquidityAssets' | 'verifiedAssets'; +interface Market { + market_cap: { + value: number; + }; + volume_24h: number; + circulating_supply: number; +} + export type SearchAsset = { address: AddressOrEth; chainId: ChainId; @@ -22,6 +30,7 @@ export type SearchAsset = { isNativeAsset?: boolean; isVerified: boolean; mainnetAddress: AddressOrEth; + market?: Market; name: string; networks: { [chainId in ChainId]?: { diff --git a/src/components/Discover/DiscoverSearch.tsx b/src/components/Discover/DiscoverSearch.tsx index 5aa4ac6d73b..59ba7baaaa6 100644 --- a/src/components/Discover/DiscoverSearch.tsx +++ b/src/components/Discover/DiscoverSearch.tsx @@ -72,7 +72,7 @@ export default function DiscoverSearch() { const lastSearchQuery = usePrevious(searchQueryForSearch); const [ensResults, setEnsResults] = useState([]); - const { swapCurrencyList, swapCurrencyListLoading } = useSearchCurrencyList(searchQueryForSearch, ChainId.mainnet); + const { swapCurrencyList, swapCurrencyListLoading } = useSearchCurrencyList(searchQueryForSearch); const profilesEnabled = useExperimentalFlag(PROFILES); const marginBottom = TAB_BAR_HEIGHT + safeAreaInsetValues.bottom + 16; @@ -83,7 +83,7 @@ export default function DiscoverSearch() { // 1. favorites // 2. verified // 3. profiles - // 4. unverified + // 4. unverified high liquidity // 5. low liquidity let list = swapCurrencyList; const listKeys = swapCurrencyList.map(item => item.key); diff --git a/src/components/expanded-state/asset/ChartExpandedState.js b/src/components/expanded-state/asset/ChartExpandedState.js index 4704316db9b..f42f50899e7 100644 --- a/src/components/expanded-state/asset/ChartExpandedState.js +++ b/src/components/expanded-state/asset/ChartExpandedState.js @@ -168,7 +168,7 @@ export default function ChartExpandedState({ asset }) { chainId: asset.chainId, network: asset.network, address: asset.address, - mainnetAddress: asset?.networks?.[useBackendNetworksStore.getState().getChainsName()[ChainId.mainnet]]?.address, + mainnetAddress: asset?.networks?.[ChainId.mainnet]?.address, } : asset; }, [asset, genericAsset, hasBalance]); diff --git a/src/handlers/tokenSearch.ts b/src/handlers/tokenSearch.ts deleted file mode 100644 index 3ea1c0c0f4a..00000000000 --- a/src/handlers/tokenSearch.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { isAddress } from '@ethersproject/address'; -import { qs } from 'url-parse'; -import { RainbowFetchClient } from '../rainbow-fetch'; -import { TokenSearchThreshold, TokenSearchTokenListId } from '@/entities'; -import { logger, RainbowError } from '@/logger'; -import { RainbowToken, TokenSearchToken } from '@/entities/tokens'; -import { useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; -import { ChainId } from '@/state/backendNetworks/types'; - -const ALL_VERIFIED_TOKENS_PARAM = '/?list=verifiedAssets'; - -const tokenSearchHttp = new RainbowFetchClient({ - baseURL: 'https://token-search.rainbow.me/v2', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - timeout: 30000, -}); - -function parseTokenSearch(assets: TokenSearchToken[]): RainbowToken[] { - const chainsName = useBackendNetworksStore.getState().getChainsName(); - return assets.map(token => { - const networkKeys = Object.keys(token.networks); - const chainId = Number(networkKeys[0]); - const network = chainsName[chainId]; - return { - ...token, - chainId, - address: token.networks['1']?.address || token.networks[chainId]?.address, - network, - mainnet_address: token.networks['1']?.address, - }; - }); -} - -export const tokenSearch = async (searchParams: { - chainId: ChainId; - fromChainId?: number | ''; - keys: (keyof RainbowToken)[]; - list: TokenSearchTokenListId; - threshold: TokenSearchThreshold; - query: string; -}): Promise => { - const queryParams: { - keys: string; - list: TokenSearchTokenListId; - threshold: TokenSearchThreshold; - query?: string; - fromChainId?: number; - } = { - keys: searchParams.keys.join(','), - list: searchParams.list, - threshold: searchParams.threshold, - query: searchParams.query, - }; - - const { chainId, query } = searchParams; - - const isAddressSearch = query && isAddress(query); - - if (isAddressSearch) { - queryParams.keys = `networks.${chainId}.address`; - } - - const url = `/?${qs.stringify(queryParams)}`; - const isSearchingVerifiedAssets = queryParams.list === 'verifiedAssets'; - - try { - const tokenSearch = await tokenSearchHttp.get<{ data: TokenSearchToken[] }>(url); - - if (isAddressSearch && isSearchingVerifiedAssets) { - if (tokenSearch && tokenSearch.data.data.length > 0) { - return parseTokenSearch(tokenSearch.data.data); - } - - const allVerifiedTokens = await tokenSearchHttp.get<{ data: TokenSearchToken[] }>(ALL_VERIFIED_TOKENS_PARAM); - - const addressQuery = query.trim().toLowerCase(); - - const addressMatchesOnOtherChains = allVerifiedTokens.data.data.filter(a => - Object.values(a.networks).some(n => n?.address === addressQuery) - ); - - return parseTokenSearch(addressMatchesOnOtherChains); - } - - if (!tokenSearch.data?.data) { - return []; - } - - return parseTokenSearch(tokenSearch.data.data); - } catch (e: any) { - logger.error(new RainbowError(`[tokenSearch]: An error occurred while searching for query`), { - query: searchParams.query, - message: e.message, - }); - - return []; - } -}; diff --git a/src/hooks/useSearchCurrencyList.ts b/src/hooks/useSearchCurrencyList.ts index f3dfca25865..0c9c52f6028 100644 --- a/src/hooks/useSearchCurrencyList.ts +++ b/src/hooks/useSearchCurrencyList.ts @@ -1,37 +1,32 @@ import lang from 'i18n-js'; -import { getAddress, isAddress } from '@ethersproject/address'; -import { EthereumAddress } from '@rainbow-me/swaps'; -import { Contract } from '@ethersproject/contracts'; import { rankings } from 'match-sorter'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { groupBy } from 'lodash'; +import { useCallback, useMemo } from 'react'; import { useTheme } from '../theme/ThemeContext'; -import usePrevious from './usePrevious'; -import { RainbowToken, TokenSearchTokenListId } from '@/entities'; -import { tokenSearch } from '@/handlers/tokenSearch'; -import { addHexPrefix, getProvider } from '@/handlers/web3'; +import { addHexPrefix } from '@/handlers/web3'; import tokenSectionTypes from '@/helpers/tokenSectionTypes'; -import { DAI_ADDRESS, erc20ABI, ETH_ADDRESS, rainbowTokenList, USDC_ADDRESS, WBTC_ADDRESS, WETH_ADDRESS } from '@/references'; -import { filterList, isLowerCaseMatch } from '@/utils'; -import { logger } from '@/logger'; -import { CROSSCHAIN_SWAPS, useExperimentalFlag } from '@/config'; +import { isLowerCaseMatch, filterList } from '@/utils'; import { IS_TEST } from '@/env'; import { useFavorites } from '@/resources/favorites'; -import { getUniqueId } from '@/utils/ethereumUtils'; import { ChainId } from '@/state/backendNetworks/types'; -import { useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; +import { getUniqueId } from '@/utils/ethereumUtils'; +import { TokenSearchResult, useTokenSearchAllNetworks } from '@/__swaps__/screens/Swap/resources/search/search'; +import { SearchAsset, TokenSearchAssetKey, TokenSearchThreshold } from '@/__swaps__/types/search'; +import { isAddress } from '@ethersproject/address'; -type swapCurrencyListType = - | 'verifiedAssets' - | 'highLiquidityAssets' - | 'lowLiquidityAssets' - | 'favoriteAssets' - | 'curatedAssets' - | 'importedAssets'; +const MAX_VERIFIED_RESULTS = 48; -type CrosschainVerifiedAssets = Record< - ChainId.mainnet | ChainId.optimism | ChainId.polygon | ChainId.bsc | ChainId.arbitrum, - RainbowToken[] ->; +const getExactMatches = (data: TokenSearchResult, query: string) => { + const isQueryAddress = isAddress(query.trim()); + return data.filter(asset => { + if (isQueryAddress) { + return !!(asset.address?.toLowerCase() === query.trim().toLowerCase()); + } + const symbolMatch = isLowerCaseMatch(asset.symbol, query); + const nameMatch = isLowerCaseMatch(asset.name, query); + return symbolMatch || nameMatch; + }); +}; const abcSort = (list: any[], key?: string) => { return list.sort((a, b) => { @@ -39,338 +34,159 @@ const abcSort = (list: any[], key?: string) => { }); }; -const searchCurrencyList = async (searchParams: { - chainId: number; - searchList: RainbowToken[] | TokenSearchTokenListId; - query: string; -}) => { - const { searchList, query, chainId } = searchParams; - const isAddress = query.match(/^(0x)?[0-9a-fA-F]{40}$/); - const keys: (keyof RainbowToken)[] = isAddress ? ['address'] : ['symbol', 'name']; - const formattedQuery = isAddress ? addHexPrefix(query).toLowerCase() : query; - if (typeof searchList === 'string') { - const threshold = isAddress ? 'CASE_SENSITIVE_EQUAL' : 'CONTAINS'; - if (chainId === ChainId.mainnet && !formattedQuery && searchList !== 'verifiedAssets') { - return []; - } - const ts = await tokenSearch({ - chainId, - keys, - list: searchList, - threshold, - query: formattedQuery, - }); - return ts; - } else { - return ( - filterList(searchList, formattedQuery, keys, { - threshold: isAddress ? rankings.CASE_SENSITIVE_EQUAL : rankings.CONTAINS, - }) || [] - ); - } +type SearchItemWithRelevance = SearchAsset & { + relevance: number; }; -const useSearchCurrencyList = (searchQuery: string, searchChainId = ChainId.mainnet) => { - const previousChainId = usePrevious(searchChainId); +const sortForDefaultList = (tokens: SearchAsset[]) => { + const curated = tokens.filter(asset => asset.highLiquidity && asset.isRainbowCurated && asset.icon_url); + return curated.sort((a, b) => (b.market?.market_cap?.value || 0) - (a.market?.market_cap?.value || 0)); +}; - const searching = useMemo(() => searchQuery !== '' || ChainId.mainnet !== searchChainId, [searchChainId, searchQuery]); +const sortTokensByRelevance = (tokens: SearchAsset[], query: string): SearchItemWithRelevance[] => { + const normalizedQuery = query.toLowerCase().trim(); + const tokenWithRelevance: SearchItemWithRelevance[] = tokens.map(token => { + const normalizedTokenName = token.name.toLowerCase(); + + const normalizedTokenSymbol = token.symbol.toLowerCase(); + const tokenNameWords = normalizedTokenName.split(' '); + const relevance = getTokenRelevance({ + token, + normalizedTokenName, + normalizedQuery, + normalizedTokenSymbol, + tokenNameWords, + }); + return { ...token, relevance }; + }); - const { favorites: favoriteAddresses, favoritesMetadata: favoriteMap } = useFavorites(); - const curatedMap = rainbowTokenList.CURATED_TOKENS; - const unfilteredFavorites = Object.values(favoriteMap).filter(token => token.networks[searchChainId]); + return tokenWithRelevance.sort((a, b) => b.relevance - a.relevance); +}; - const [loading, setLoading] = useState(true); - const [favoriteAssets, setFavoriteAssets] = useState([]); - const [importedAssets, setImportedAssets] = useState([]); - const [highLiquidityAssets, setHighLiquidityAssets] = useState([]); - const [lowLiquidityAssets, setLowLiquidityAssets] = useState([]); - const [verifiedAssets, setVerifiedAssets] = useState([]); - const [fetchingCrosschainAssets, setFetchingCrosschainAssets] = useState(false); - const [crosschainVerifiedAssets, setCrosschainVerifiedAssets] = useState({ - [ChainId.apechain]: [], - [ChainId.arbitrum]: [], - [ChainId.avalanche]: [], - [ChainId.base]: [], - [ChainId.bsc]: [], - [ChainId.blast]: [], - [ChainId.degen]: [], - [ChainId.gnosis]: [], - [ChainId.gravity]: [], - [ChainId.ink]: [], - [ChainId.linea]: [], - [ChainId.mainnet]: [], - [ChainId.optimism]: [], - [ChainId.polygon]: [], - [ChainId.sanko]: [], - [ChainId.scroll]: [], - [ChainId.zksync]: [], - [ChainId.zora]: [], - }); +// higher number indicates higher relevance +const getTokenRelevance = ({ + token, + normalizedTokenName, + normalizedQuery, + normalizedTokenSymbol, + tokenNameWords, +}: { + token: SearchAsset; + normalizedTokenName: string; + normalizedQuery: string; + normalizedTokenSymbol?: string; + tokenNameWords: string[]; +}) => { + // High relevance: Leading word in token name starts with query or exact match on symbol + if (normalizedTokenName.startsWith(normalizedQuery) || (normalizedTokenSymbol && normalizedTokenSymbol === normalizedQuery)) { + return 5; + } - const crosschainSwapsEnabled = useExperimentalFlag(CROSSCHAIN_SWAPS); + // Medium relevance: Non-leading word in token name starts with query + if (tokenNameWords.some((word, index) => index !== 0 && word.startsWith(normalizedQuery))) { + return 4; + } - const isFavorite = useCallback( - (address: EthereumAddress) => favoriteAddresses.map(a => a?.toLowerCase()).includes(address?.toLowerCase()), - [favoriteAddresses] - ); - const handleSearchResponse = useCallback( - (tokens: RainbowToken[]): RainbowToken[] => { - // These transformations are necessary for L2 tokens to match our spec - return (tokens || []) - .map(token => { - const t: RainbowToken = { - ...token, - address: token?.address || token.uniqueId.toLowerCase(), - } as RainbowToken; + // Low relevance: Token name contains query + if (tokenNameWords.some(word => word.includes(normalizedQuery))) { + return 3; + } - return t; - }) - .filter(({ address }) => !isFavorite(address)); - }, - [isFavorite] - ); + return 0; +}; - const getCurated = useCallback(() => { - const addresses = favoriteAddresses.map(a => a.toLowerCase()); - return Object.values(curatedMap) - .filter(({ address }) => !addresses.includes(address.toLowerCase())) - .sort((t1, t2) => { - const { address: address1, name: name1 } = t1; - const { address: address2, name: name2 } = t2; - const mainnetPriorityTokens = [ETH_ADDRESS, WETH_ADDRESS, DAI_ADDRESS, USDC_ADDRESS, WBTC_ADDRESS]; - const rankA = mainnetPriorityTokens.findIndex(address => address === address1.toLowerCase()); - const rankB = mainnetPriorityTokens.findIndex(address => address === address2.toLowerCase()); - const aIsRanked = rankA > -1; - const bIsRanked = rankB > -1; - if (aIsRanked) { - if (bIsRanked) { - return rankA > rankB ? -1 : 1; - } - return -1; +const useSearchCurrencyList = (searchQuery: string) => { + const searching = useMemo(() => searchQuery !== '', [searchQuery]); + + const { favorites: favoriteAddresses, favoritesMetadata: favoriteMap } = useFavorites(); + const unfilteredFavorites = useMemo(() => { + return Object.values(favoriteMap) + .filter(token => token.networks[ChainId.mainnet]) + .map(favToken => ({ + ...favToken, + favorite: true, + mainnetAddress: favToken.networks?.[ChainId.mainnet]?.address || favToken.mainnet_address, + uniqueId: getUniqueId(favToken.address, ChainId.mainnet), + })) as SearchAsset[]; + }, [favoriteMap]); + + const memoizedData = useMemo(() => { + const queryIsAddress = isAddress(searchQuery); + const keys: TokenSearchAssetKey[] = queryIsAddress ? ['address'] : ['name', 'symbol']; + const threshold: TokenSearchThreshold = queryIsAddress ? 'CASE_SENSITIVE_EQUAL' : 'CONTAINS'; + const enableUnverifiedSearch = searchQuery.length > 2; + + return { + queryIsAddress, + keys, + threshold, + enableUnverifiedSearch, + }; + }, [searchQuery]); + + const favoriteAssets = useMemo(() => { + if (searchQuery === '') { + return unfilteredFavorites; + } else { + return filterList( + unfilteredFavorites || [], + memoizedData.queryIsAddress ? addHexPrefix(searchQuery).toLowerCase() : searchQuery, + memoizedData.keys, + { + threshold: memoizedData.queryIsAddress ? rankings.CASE_SENSITIVE_EQUAL : rankings.CONTAINS, } - return bIsRanked ? 1 : name1?.localeCompare(name2); - }); - }, [curatedMap, favoriteAddresses]); + ); + } + }, [memoizedData.keys, memoizedData.queryIsAddress, searchQuery, unfilteredFavorites]); - const getFavorites = useCallback(async () => { - return searching - ? await searchCurrencyList({ - searchList: unfilteredFavorites as RainbowToken[], - query: searchQuery, - chainId: searchChainId, - }) - : unfilteredFavorites; - }, [searchChainId, searchQuery, searching, unfilteredFavorites]); + const { colors } = useTheme(); + + const selectTopSearchResults = useCallback( + (data: TokenSearchResult) => { + const results = data.filter(asset => { + const isFavorite = favoriteAddresses.map(a => a?.toLowerCase()).includes(asset.uniqueId?.toLowerCase()); + if (isFavorite) return false; - const getImportedAsset = useCallback( - async (searchQuery: string, chainId: number): Promise => { - if (searching) { - if (isAddress(searchQuery)) { - const tokenListEntry = rainbowTokenList.RAINBOW_TOKEN_LIST[searchQuery.toLowerCase()]; - if (tokenListEntry) { - return [tokenListEntry]; - } - const provider = getProvider({ chainId }); - const tokenContract = new Contract(searchQuery, erc20ABI, provider); - try { - const [name, symbol, decimals, address] = await Promise.all([ - tokenContract.name(), - tokenContract.symbol(), - tokenContract.decimals(), - getAddress(searchQuery), - ]); - const uniqueId = getUniqueId(address, chainId); + const hasIcon = asset.icon_url; + const isMatch = hasIcon || searchQuery.length > 2; - return [ - { - chainId, - address, - decimals, - favorite: false, - highLiquidity: false, - isRainbowCurated: false, - isVerified: false, - name, - networks: { - [chainId]: { - address, - decimals, - }, - }, - symbol, - network: useBackendNetworksStore.getState().getChainsName()[chainId], - uniqueId, - } as RainbowToken, - ]; - } catch (e) { - logger.warn('[useSearchCurrencyList]: error getting token data', { error: (e as Error).message }); - return null; - } + if (!isMatch) { + const crosschainMatch = getExactMatches([asset], searchQuery); + return crosschainMatch.length > 0; } - } - return null; - }, - [searching] - ); - const getCrosschainVerifiedAssetsForNetwork = useCallback( - async (chainId: ChainId) => { - const results = await searchCurrencyList({ - searchList: 'verifiedAssets', - query: '', - chainId, + return isMatch; }); - setCrosschainVerifiedAssets(state => ({ - ...state, - [chainId]: handleSearchResponse(results || []), - })); + const topResults = searchQuery === '' ? sortForDefaultList(results) : sortTokensByRelevance(results, searchQuery); + return topResults.slice(0, MAX_VERIFIED_RESULTS); }, - [handleSearchResponse] + [searchQuery, favoriteAddresses] ); - const getCrosschainVerifiedAssets = useCallback(async () => { - const crosschainAssetRequests: Promise[] = []; - Object.keys(crosschainVerifiedAssets).forEach(chainId => { - crosschainAssetRequests.push(getCrosschainVerifiedAssetsForNetwork(Number(chainId))); - }); - await Promise.all(crosschainAssetRequests); - }, [crosschainVerifiedAssets, getCrosschainVerifiedAssetsForNetwork]); - - const getResultsForAssetType = useCallback( - async (assetType: swapCurrencyListType) => { - switch (assetType) { - case 'verifiedAssets': - setVerifiedAssets( - handleSearchResponse( - await searchCurrencyList({ - searchList: assetType, - query: searchQuery, - chainId: searchChainId, - }) - ) - ); - break; - case 'highLiquidityAssets': - setHighLiquidityAssets( - handleSearchResponse( - await searchCurrencyList({ - searchList: assetType, - query: searchQuery, - chainId: searchChainId, - }) - ) - ); - break; - case 'lowLiquidityAssets': - setLowLiquidityAssets( - handleSearchResponse( - await searchCurrencyList({ - searchList: assetType, - query: searchQuery, - chainId: searchChainId, - }) - ) - ); - break; - case 'favoriteAssets': - setFavoriteAssets((await getFavorites()) || []); - break; - case 'importedAssets': { - const importedAssetResult = await getImportedAsset(searchQuery, searchChainId); - if (importedAssetResult) { - setImportedAssets(handleSearchResponse(importedAssetResult)); - } - break; - } - } + const { data: searchResultAssets, isFetching: loading } = useTokenSearchAllNetworks( + { + query: searchQuery, }, - [getFavorites, getImportedAsset, handleSearchResponse, searchQuery, searchChainId] - ); - - const search = useCallback(async () => { - const categories: swapCurrencyListType[] = - searchChainId === ChainId.mainnet - ? ['favoriteAssets', 'highLiquidityAssets', 'verifiedAssets', 'importedAssets'] - : ['verifiedAssets', 'importedAssets']; - setLoading(true); - await Promise.all(categories.map(assetType => getResultsForAssetType(assetType))); - }, [searchChainId, getResultsForAssetType]); - - const slowSearch = useCallback(async () => { - try { - await getResultsForAssetType('lowLiquidityAssets'); - // eslint-disable-next-line no-empty - } catch (e) { - } finally { - setLoading(false); + { + select: selectTopSearchResults, + staleTime: 10 * 60 * 1_000, // 10 min } - }, [getResultsForAssetType]); - - const clearSearch = useCallback(() => { - getResultsForAssetType('curatedAssets'); - setLowLiquidityAssets([]); - setHighLiquidityAssets([]); - setVerifiedAssets([]); - setImportedAssets([]); - }, [getResultsForAssetType]); - - const wasSearching = usePrevious(searching); - const previousSearchQuery = usePrevious(searchQuery); - - useEffect(() => { - if (!fetchingCrosschainAssets && crosschainSwapsEnabled) { - setFetchingCrosschainAssets(true); - getCrosschainVerifiedAssets(); - } - }, [getCrosschainVerifiedAssets, fetchingCrosschainAssets, crosschainSwapsEnabled]); - - useEffect(() => { - const doSearch = async () => { - if ((searching && !wasSearching) || (searching && previousSearchQuery !== searchQuery) || searchChainId !== previousChainId) { - if (searchChainId === ChainId.mainnet) { - search(); - slowSearch(); - } else { - await search(); - setLowLiquidityAssets([]); - setHighLiquidityAssets([]); - setLoading(false); - } - } else { - clearSearch(); - } - }; - doSearch(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searching, searchQuery, searchChainId]); - - const { colors } = useTheme(); + ); const currencyList = useMemo(() => { const list = []; - - if (searching) { - const importedAsset = importedAssets?.[0]; - let verifiedAssetsWithImport = verifiedAssets; - let highLiquidityAssetsWithImport = highLiquidityAssets; - let lowLiquidityAssetsWithoutImport = lowLiquidityAssets; - const verifiedAddresses = verifiedAssets.map(({ uniqueId }) => uniqueId.toLowerCase()); - const highLiquidityAddresses = verifiedAssets.map(({ uniqueId }) => uniqueId.toLowerCase()); - // this conditional prevents the imported token from jumping - // sections if verified/highliquidity search responds later - // than the contract checker in getImportedAsset - if (importedAsset && !isFavorite(importedAsset?.address)) { - lowLiquidityAssetsWithoutImport = lowLiquidityAssets.filter(({ address }) => address.toLowerCase() !== importedAsset?.address); - if (importedAsset?.isVerified && !verifiedAddresses.includes(importedAsset?.address.toLowerCase())) { - verifiedAssetsWithImport = [importedAsset, ...verifiedAssets]; - } else { - if (!highLiquidityAddresses.includes(importedAsset?.address.toLowerCase())) { - highLiquidityAssetsWithImport = [importedAsset, ...highLiquidityAssets]; - } - } + const { verifiedAssets, highLiquidityAssets, lowLiquidityAssets } = groupBy(searchResultAssets, searchResult => { + if (searchResult.isVerified) { + return 'verifiedAssets'; + } else if (!searchResult.isVerified && searchResult.highLiquidity) { + return 'highLiquidityAssets'; + } else { + return 'lowLiquidityAssets'; } + }); - if (favoriteAssets?.length && searchChainId === ChainId.mainnet) { + if (searching) { + if (favoriteAssets?.length) { list.push({ color: colors.yellowFavorite, data: abcSort(favoriteAssets, 'name'), @@ -378,96 +194,50 @@ const useSearchCurrencyList = (searchQuery: string, searchChainId = ChainId.main title: lang.t(`exchange.token_sections.${tokenSectionTypes.favoriteTokenSection}`), }); } - if (verifiedAssetsWithImport?.length) { + if (verifiedAssets?.length) { list.push({ - data: verifiedAssetsWithImport, + data: verifiedAssets, key: 'verified', title: lang.t(`exchange.token_sections.${tokenSectionTypes.verifiedTokenSection}`), useGradientText: !IS_TEST, }); } - if (highLiquidityAssetsWithImport?.length) { + if (highLiquidityAssets?.length) { list.push({ - data: highLiquidityAssetsWithImport, + data: highLiquidityAssets, key: 'highLiquidity', title: lang.t(`exchange.token_sections.${tokenSectionTypes.unverifiedTokenSection}`), }); } - if (lowLiquidityAssetsWithoutImport?.length) { + if (lowLiquidityAssets?.length) { list.push({ - data: lowLiquidityAssetsWithoutImport, + data: lowLiquidityAssets, key: 'lowLiquidity', title: lang.t(`exchange.token_sections.${tokenSectionTypes.lowLiquidityTokenSection}`), }); } } else { - const curatedAssets = searchChainId === ChainId.mainnet && getCurated(); - - if (unfilteredFavorites?.length) { + if (favoriteAssets?.length) { list.push({ color: colors.yellowFavorite, - data: abcSort(unfilteredFavorites, 'name'), + data: abcSort(favoriteAssets, 'name'), key: 'unfilteredFavorites', title: lang.t(`exchange.token_sections.${tokenSectionTypes.favoriteTokenSection}`), }); } - if (curatedAssets && curatedAssets.length) { + if (verifiedAssets?.length) { list.push({ - data: curatedAssets, - key: 'curated', + data: verifiedAssets, + key: 'verified', title: lang.t(`exchange.token_sections.${tokenSectionTypes.verifiedTokenSection}`), useGradientText: !IS_TEST, }); } } return list; - }, [ - verifiedAssets, - searching, - importedAssets, - highLiquidityAssets, - lowLiquidityAssets, - isFavorite, - favoriteAssets, - searchChainId, - colors.yellowFavorite, - getCurated, - unfilteredFavorites, - ]); - - const crosschainExactMatches = useMemo(() => { - if (currencyList.length) return []; - if (!searchQuery) return []; - const exactMatches: RainbowToken[] = []; - Object.keys(crosschainVerifiedAssets).forEach(chainId => { - const currentNetworkChainId = Number(chainId); - if (currentNetworkChainId !== searchChainId) { - // including goerli in our networks type is causing this type issue - const exactMatch = crosschainVerifiedAssets[currentNetworkChainId].find((asset: RainbowToken) => { - const symbolMatch = isLowerCaseMatch(asset?.symbol, searchQuery); - const nameMatch = isLowerCaseMatch(asset?.name, searchQuery); - return symbolMatch || nameMatch; - }); - if (exactMatch) { - exactMatches.push({ ...exactMatch, chainId: currentNetworkChainId }); - } - } - }); - if (exactMatches?.length) { - return [ - { - data: exactMatches, - key: 'verified', - title: lang.t(`exchange.token_sections.${tokenSectionTypes.crosschainMatchSection}`), - useGradientText: !IS_TEST, - }, - ]; - } - return []; - }, [crosschainVerifiedAssets, currencyList.length, searchChainId, searchQuery]); + }, [searchResultAssets, searching, favoriteAssets, colors.yellowFavorite]); return { - crosschainExactMatches, swapCurrencyList: currencyList, swapCurrencyListLoading: loading, };