diff --git a/src/__swaps__/assets/aggregators/0x.png b/src/__swaps__/assets/aggregators/0x.png new file mode 100644 index 00000000000..4dbe4fbcce7 Binary files /dev/null and b/src/__swaps__/assets/aggregators/0x.png differ diff --git a/src/__swaps__/assets/aggregators/1inch.png b/src/__swaps__/assets/aggregators/1inch.png new file mode 100644 index 00000000000..030ec15ae5c Binary files /dev/null and b/src/__swaps__/assets/aggregators/1inch.png differ diff --git a/src/__swaps__/assets/aggregators/rainbow.png b/src/__swaps__/assets/aggregators/rainbow.png new file mode 100644 index 00000000000..cc6379b750b Binary files /dev/null and b/src/__swaps__/assets/aggregators/rainbow.png differ diff --git a/src/__swaps__/screens/Swap/hooks/useDebounce.ts b/src/__swaps__/screens/Swap/hooks/useDebounce.ts new file mode 100644 index 00000000000..38eab1b99e7 --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/useDebounce.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +export function useDebounce(value: T, delay: number) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect( + () => { + // Update debounced value after delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + // Cancel the timeout if value changes (also on delay change or unmount) + // This is how we prevent debounced value from updating if value is changed ... + // .. within the delay period. Timeout gets cleared and restarted. + return () => { + clearTimeout(handler); + }; + }, + [value, delay] // Only re-call effect if value or delay changes + ); + return debouncedValue; +} diff --git a/src/__swaps__/screens/Swap/hooks/usePrevious.ts b/src/__swaps__/screens/Swap/hooks/usePrevious.ts new file mode 100644 index 00000000000..b6d9f759830 --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/usePrevious.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/src/__swaps__/screens/Swap/hooks/useSwapAssets.ts b/src/__swaps__/screens/Swap/hooks/useSwapAssets.ts new file mode 100644 index 00000000000..0b138c231d2 --- /dev/null +++ b/src/__swaps__/screens/Swap/hooks/useSwapAssets.ts @@ -0,0 +1,167 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Hex } from 'viem'; + +import { selectUserAssetsList, selectUserAssetsListByChainId } from '../resources/_selectors/assets'; + +import { useUserAssets, useAssets } from '@/__swaps__/screens/Swap/resources/assets'; +import { ParsedAsset, ParsedAssetsDictByChain, ParsedSearchAsset } from '@/__swaps__/screens/Swap/types/assets'; +import { ChainId } from '@/__swaps__/screens/Swap/types/chains'; +import { SearchAsset } from '@/__swaps__/screens/Swap/types/search'; +import { parseSearchAsset } from '@/__swaps__/screens/Swap/utils/assets'; +import { isLowerCaseMatch } from '@/__swaps__/screens/Swap/utils/strings'; +import type { SortMethod } from '@/__swaps__/screens/Swap/types/swap'; +import { useDebounce } from '@/__swaps__/screens/Swap/hooks/useDebounce'; +import { usePrevious } from '@/__swaps__/screens/Swap/hooks/usePrevious'; +// import { useSearchCurrencyLists } from '../useSearchCurrencyLists'; +import { useAccountSettings } from '@/hooks'; + +const sortBy = (by: SortMethod) => { + switch (by) { + case 'token': + return selectUserAssetsList; + case 'chain': + return selectUserAssetsListByChainId; + } +}; + +export const isSameAsset = (a1: Pick, a2: Pick) => + +a1.chainId === +a2.chainId && isLowerCaseMatch(a1.address, a2.address); + +const isSameAssetInDiffChains = (a1?: Pick | null, a2?: Pick | null) => { + if (!a1?.networks || !a2) return false; + return Object.values(a1.networks).some(assetInNetwork => assetInNetwork?.address === a2.address); +}; + +export const useSwapAssets = ({ bridge }: { bridge: boolean }) => { + const { accountAddress: currentAddress, nativeCurrency: currentCurrency } = useAccountSettings(); + + const [assetToSell, setAssetToSellState] = useState(null); + const [assetToBuy, setAssetToBuyState] = useState(null); + + const prevAssetToSell = usePrevious(assetToSell); + + const [outputChainId, setOutputChainId] = useState(ChainId.mainnet); + + const [sortMethod, setSortMethod] = useState('token'); + + const [assetToSellFilter, setAssetToSellFilter] = useState(''); + const [assetToBuyFilter, setAssetToBuyFilter] = useState(''); + + const debouncedAssetToSellFilter = useDebounce(assetToSellFilter, 200); + const debouncedAssetToBuyFilter = useDebounce(assetToBuyFilter, 200); + + const { data: userAssets = [] } = useUserAssets( + { + address: currentAddress as Hex, + currency: currentCurrency, + }, + { + select: data => { + const filteredAssetsDictByChain = Object.keys(data).reduce((acc, key) => { + const chainKey = Number(key); + acc[chainKey] = data[chainKey]; + return acc; + }, {} as ParsedAssetsDictByChain); + return sortBy(sortMethod)(filteredAssetsDictByChain); + }, + } + ); + + const filteredAssetsToSell = useMemo(() => { + return debouncedAssetToSellFilter + ? userAssets.filter(({ name, symbol, address }) => + [name, symbol, address].reduce( + (res, param) => res || param.toLowerCase().startsWith(debouncedAssetToSellFilter.toLowerCase()), + false + ) + ) + : userAssets; + }, [debouncedAssetToSellFilter, userAssets]) as ParsedSearchAsset[]; + + // const { results: searchAssetsToBuySections } = useSearchCurrencyLists({ + // inputChainId: assetToSell?.chainId, + // outputChainId, + // assetToSell, + // searchQuery: debouncedAssetToBuyFilter, + // bridge, + // }); + + const { data: buyPriceData = [] } = useAssets({ + assetAddresses: assetToBuy ? [assetToBuy?.address] : [], + chainId: outputChainId, + currency: currentCurrency, + }); + + const { data: sellPriceData = [] } = useAssets({ + assetAddresses: assetToSell ? [assetToSell?.address] : [], + chainId: outputChainId, + currency: currentCurrency, + }); + + const assetToBuyWithPrice = useMemo( + () => Object.values(buyPriceData || {})?.find(asset => asset.uniqueId === assetToBuy?.uniqueId), + [assetToBuy, buyPriceData] + ); + + const assetToSellWithPrice = useMemo( + () => Object.values(sellPriceData || {})?.find(asset => asset.uniqueId === assetToBuy?.uniqueId), + [assetToBuy, sellPriceData] + ); + + const parsedAssetToBuy = useMemo(() => { + if (!assetToBuy) return null; + const userAsset = userAssets.find(userAsset => isSameAsset(userAsset, assetToBuy)); + return parseSearchAsset({ + assetWithPrice: assetToBuyWithPrice, + searchAsset: assetToBuy, + userAsset, + }); + }, [assetToBuy, assetToBuyWithPrice, userAssets]); + + const parsedAssetToSell = useMemo(() => { + if (!assetToSell) return null; + const userAsset = userAssets.find(userAsset => isSameAsset(userAsset, assetToSell)); + return parseSearchAsset({ + assetWithPrice: assetToSellWithPrice, + searchAsset: assetToSell, + userAsset, + }); + }, [assetToSell, assetToSellWithPrice, userAssets]); + + const setAssetToBuy = useCallback((asset: ParsedSearchAsset | null) => { + setAssetToBuyState(asset); + }, []); + + const setAssetToSell = useCallback( + (asset: ParsedSearchAsset | null) => { + if (assetToBuy && asset && assetToBuy?.address === asset?.address && assetToBuy?.chainId === asset?.chainId) { + setAssetToBuyState(prevAssetToSell === undefined ? null : prevAssetToSell); + } + // if it's in bridge mode, the asset to sell changes, and it's not the same asset in different chains, + // we clear the asset to buy (because that would be a crosschain swap) + if (bridge && !isSameAssetInDiffChains(asset, assetToBuy)) { + setAssetToBuyState(null); + } + setAssetToSellState(asset); + asset?.chainId && setOutputChainId(asset?.chainId); + }, + [assetToBuy, prevAssetToSell, bridge] + ); + + return { + assetsToSell: filteredAssetsToSell, + assetToSellFilter, + // assetsToBuy: searchAssetsToBuySections, + assetToBuyFilter, + sortMethod, + assetToSell: parsedAssetToSell, + assetToBuy: parsedAssetToBuy, + outputChainId: bridge ? undefined : outputChainId, + setSortMethod, + setAssetToSell, + setAssetToBuy, + setOutputChainId: bridge ? undefined : setOutputChainId, + setAssetToSellFilter, + setAssetToBuyFilter, + }; +}; diff --git a/src/__swaps__/screens/Swap/resources/_selectors/assets.ts b/src/__swaps__/screens/Swap/resources/_selectors/assets.ts new file mode 100644 index 00000000000..27e32643257 --- /dev/null +++ b/src/__swaps__/screens/Swap/resources/_selectors/assets.ts @@ -0,0 +1,74 @@ +import { ParsedAssetsDict, ParsedAssetsDictByChain, ParsedUserAsset, UniqueId } from '../../types/assets'; +import { ChainId } from '../../types/chains'; +import { deriveAddressAndChainWithUniqueId } from '../../utils/address'; +import { add } from '../../utils/numbers'; + +// selectors +export function selectUserAssetsList(assets: ParsedAssetsDictByChain) { + return Object.values(assets) + .map(chainAssets => Object.values(chainAssets)) + .flat() + .sort((a: ParsedUserAsset, b: ParsedUserAsset) => parseFloat(b?.native?.balance?.amount) - parseFloat(a?.native?.balance?.amount)); +} + +export function selectUserAssetsFilteringSmallBalancesList(assets: ParsedAssetsDictByChain) { + return selectUserAssetsList(assets).filter(a => !a.smallBalance); +} + +export function selectUserAssetsDictByChain(assets: ParsedAssetsDictByChain) { + return assets; +} + +export function selectUserAssetsListByChainId(assets: ParsedAssetsDictByChain) { + const assetsByNetwork = [ + assets?.[ChainId.mainnet], + assets?.[ChainId.optimism], + assets?.[ChainId.polygon], + assets?.[ChainId.arbitrum], + assets?.[ChainId.base], + assets?.[ChainId.zora], + assets?.[ChainId.bsc], + assets?.[ChainId.avalanche], + ].flat(); + return assetsByNetwork + .map(chainAssets => + Object.values(chainAssets).sort( + (a: ParsedUserAsset, b: ParsedUserAsset) => parseFloat(b?.native?.balance?.amount) - parseFloat(a?.native?.balance?.amount) + ) + ) + .flat(); +} + +export function selectUserAssetAddressMapByChainId(assets: ParsedAssetsDictByChain) { + const mapAddresses = (list: ParsedAssetsDict = {}) => Object.values(list).map(i => i.address); + return { + [ChainId.mainnet]: mapAddresses(assets[ChainId.mainnet]) || [], + [ChainId.optimism]: mapAddresses(assets[ChainId.optimism]) || [], + [ChainId.bsc]: mapAddresses(assets[ChainId.bsc]) || [], + [ChainId.polygon]: mapAddresses(assets[ChainId.polygon]) || [], + [ChainId.arbitrum]: mapAddresses(assets[ChainId.arbitrum]) || [], + [ChainId.base]: mapAddresses(assets[ChainId.base]) || [], + [ChainId.zora]: mapAddresses(assets[ChainId.zora]) || [], + [ChainId.avalanche]: mapAddresses(assets[ChainId.avalanche]) || [], + }; +} + +// selector generators +export function selectUserAssetWithUniqueId(uniqueId: UniqueId) { + return (assets: ParsedAssetsDictByChain) => { + const { chain } = deriveAddressAndChainWithUniqueId(uniqueId); + return assets?.[chain]?.[uniqueId]; + }; +} + +export function selectUserAssetsBalance(assets: ParsedAssetsDictByChain) { + const networksTotalBalance = Object.values(assets).map(assetsOnject => { + const assetsNetwork = Object.values(assetsOnject); + const networkBalance = assetsNetwork + .map(asset => asset.native.balance.amount) + .reduce((prevBalance, currBalance) => add(prevBalance, currBalance), '0'); + return networkBalance; + }); + const totalAssetsBalance = networksTotalBalance.reduce((prevBalance, currBalance) => add(prevBalance, currBalance), '0'); + return totalAssetsBalance; +} diff --git a/src/__swaps__/screens/Swap/resources/assets/assets.ts b/src/__swaps__/screens/Swap/resources/assets/assets.ts new file mode 100644 index 00000000000..b8497b74920 --- /dev/null +++ b/src/__swaps__/screens/Swap/resources/assets/assets.ts @@ -0,0 +1,103 @@ +import { useQuery } from '@tanstack/react-query'; + +import { requestMetadata } from '@/graphql'; +import { QueryConfigWithSelect, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; +import { SupportedCurrencyKey } from '@/references'; +import { AddressOrEth, AssetMetadata, ParsedAsset, UniqueId } from '../../types/assets'; +import { ChainId } from '../../types/chains'; +import { chunkArray, createAssetQuery, parseAssetMetadata } from '../../utils/assets'; +import { RainbowError, logger } from '@/logger'; + +export const ASSETS_TIMEOUT_DURATION = 10000; +const ASSETS_REFETCH_INTERVAL = 60000; + +// /////////////////////////////////////////////// +// Query Types + +export type AssetsQueryArgs = { + assetAddresses: AddressOrEth[]; + chainId: ChainId; + currency: SupportedCurrencyKey; +}; + +// /////////////////////////////////////////////// +// Query Key + +const assetsQueryKey = ({ assetAddresses, chainId, currency }: AssetsQueryArgs) => + createQueryKey('assets', { assetAddresses, chainId, currency }, { persisterVersion: 2 }); + +type AssetsQueryKey = ReturnType; + +// /////////////////////////////////////////////// +// Query Function + +export async function assetsQueryFunction({ + queryKey: [{ assetAddresses, chainId, currency }], +}: QueryFunctionArgs): Promise<{ + [key: UniqueId]: ParsedAsset; +}> { + try { + if (!assetAddresses || !assetAddresses.length) return {}; + const batches = chunkArray([...assetAddresses], 10); // chunking because a full batch would throw 413 + const batchResults = batches.map(batchedQuery => + requestMetadata(createAssetQuery(batchedQuery, chainId, currency, true), { + timeout: ASSETS_TIMEOUT_DURATION, + }) + ) as Promise[]>[]; + const results = (await Promise.all(batchResults)) + .flat() + .map(r => Object.values(r)) + .flat(); + const parsedAssets = parseAssets(results, chainId, currency); + return parsedAssets; + } catch (e) { + logger.error(new RainbowError('assetsQueryFunction: '), { + message: (e as Error)?.message, + }); + return {}; + } +} + +type AssetsQueryResult = QueryFunctionResult; + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchAssets( + { assetAddresses, chainId, currency }: AssetsQueryArgs, + config: QueryConfigWithSelect = {} +) { + return await queryClient.fetchQuery(assetsQueryKey({ assetAddresses, chainId, currency }), assetsQueryFunction, config); +} + +function parseAssets(assets: AssetMetadata[], chainId: ChainId, currency: SupportedCurrencyKey) { + return assets.reduce( + (assetsDict, asset) => { + const address = asset.networks?.[chainId]?.address; + if (address) { + const parsedAsset = parseAssetMetadata({ + address, + asset, + chainId, + currency, + }); + assetsDict[parsedAsset?.uniqueId] = parsedAsset; + } + return assetsDict; + }, + {} as Record + ); +} + +// /////////////////////////////////////////////// +// Query Hook + +export function useAssets( + { assetAddresses, chainId, currency }: AssetsQueryArgs, + config: QueryConfigWithSelect = {} +) { + return useQuery(assetsQueryKey({ assetAddresses, chainId, currency }), assetsQueryFunction, { + ...config, + refetchInterval: ASSETS_REFETCH_INTERVAL, + }); +} diff --git a/src/__swaps__/screens/Swap/resources/assets/index.ts b/src/__swaps__/screens/Swap/resources/assets/index.ts new file mode 100644 index 00000000000..f485a083154 --- /dev/null +++ b/src/__swaps__/screens/Swap/resources/assets/index.ts @@ -0,0 +1,5 @@ +export { useAssets } from './assets'; +export { useUserAssets } from './userAssets'; +export type { UserAssetsArgs } from './userAssets'; +export { useUserAssetsByChain } from './userAssetsByChain'; +export type { UserAssetsByChainArgs } from './userAssetsByChain'; diff --git a/src/__swaps__/screens/Swap/resources/assets/userAssets.ts b/src/__swaps__/screens/Swap/resources/assets/userAssets.ts new file mode 100644 index 00000000000..73dc22c333d --- /dev/null +++ b/src/__swaps__/screens/Swap/resources/assets/userAssets.ts @@ -0,0 +1,205 @@ +import { useQuery } from '@tanstack/react-query'; +import { Address } from 'viem'; + +import { QueryConfigWithSelect, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; +import { SupportedCurrencyKey, SUPPORTED_CHAIN_IDS } from '@/references'; +import { ParsedAssetsDictByChain, ZerionAsset } from '../../types/assets'; +import { ChainId } from '../../types/chains'; +import { AddressAssetsReceivedMessage } from '../../types/refraction'; +import { filterAsset, parseUserAsset } from '@/__swaps__/screens/Swap/utils/assets'; +import { greaterThan } from '@/__swaps__/screens/Swap/utils/numbers'; +import { RainbowError, logger } from '@/logger'; + +import { fetchUserAssetsByChain } from './userAssetsByChain'; +import { RainbowFetchClient } from '@/rainbow-fetch'; +import { ADDYS_API_KEY } from 'react-native-dotenv'; + +const addysHttp = new RainbowFetchClient({ + baseURL: 'https://addys.p.rainbow.me/v3', + headers: { + Authorization: `Bearer ${ADDYS_API_KEY}`, + }, +}); + +const USER_ASSETS_REFETCH_INTERVAL = 60000; +const USER_ASSETS_TIMEOUT_DURATION = 20000; +export const USER_ASSETS_STALE_INTERVAL = 30000; + +// /////////////////////////////////////////////// +// Query Types + +export type UserAssetsArgs = { + address?: Address; + currency: SupportedCurrencyKey; +}; + +type SetUserAssetsArgs = { + address?: Address; + currency: SupportedCurrencyKey; + userAssets?: UserAssetsResult; +}; + +type SetUserDefaultsArgs = { + address?: Address; + currency: SupportedCurrencyKey; + staleTime: number; +}; + +type FetchUserAssetsArgs = { + address?: Address; + currency: SupportedCurrencyKey; +}; + +// /////////////////////////////////////////////// +// Query Key + +export const userAssetsQueryKey = ({ address, currency }: UserAssetsArgs) => + createQueryKey('userAssets', { address, currency }, { persisterVersion: 1 }); + +type UserAssetsQueryKey = ReturnType; + +// /////////////////////////////////////////////// +// Query Function + +export const userAssetsFetchQuery = ({ address, currency }: FetchUserAssetsArgs) => { + queryClient.fetchQuery(userAssetsQueryKey({ address, currency }), userAssetsQueryFunction); +}; + +export const userAssetsSetQueryDefaults = ({ address, currency, staleTime }: SetUserDefaultsArgs) => { + queryClient.setQueryDefaults(userAssetsQueryKey({ address, currency }), { + staleTime, + }); +}; + +export const userAssetsSetQueryData = ({ address, currency, userAssets }: SetUserAssetsArgs) => { + queryClient.setQueryData(userAssetsQueryKey({ address, currency }), userAssets); +}; + +async function userAssetsQueryFunction({ queryKey: [{ address, currency }] }: QueryFunctionArgs) { + const cache = queryClient.getQueryCache(); + const cachedUserAssets = (cache.find(userAssetsQueryKey({ address, currency }))?.state?.data || {}) as ParsedAssetsDictByChain; + try { + const url = `/${SUPPORTED_CHAIN_IDS.join(',')}/${address}/assets`; + const res = await addysHttp.get(url, { + params: { + currency: currency.toLowerCase(), + }, + timeout: USER_ASSETS_TIMEOUT_DURATION, + }); + const chainIdsInResponse = res?.data?.meta?.chain_ids || []; + const chainIdsWithErrorsInResponse = res?.data?.meta?.chain_ids_with_errors || []; + const assets = res?.data?.payload?.assets || []; + if (address) { + userAssetsQueryFunctionRetryByChain({ + address, + chainIds: chainIdsWithErrorsInResponse, + currency, + }); + if (assets.length && chainIdsInResponse.length) { + const parsedAssetsDict = await parseUserAssets({ + assets, + chainIds: chainIdsInResponse, + currency, + }); + + for (const missingChainId of chainIdsWithErrorsInResponse) { + if (cachedUserAssets[missingChainId]) { + parsedAssetsDict[missingChainId] = cachedUserAssets[missingChainId]; + } + } + return parsedAssetsDict; + } + } + return cachedUserAssets; + } catch (e) { + logger.error(new RainbowError('userAssetsQueryFunction: '), { + message: (e as Error)?.message, + }); + return cachedUserAssets; + } +} + +type UserAssetsResult = QueryFunctionResult; + +async function userAssetsQueryFunctionRetryByChain({ + address, + chainIds, + currency, +}: { + address: Address; + chainIds: ChainId[]; + currency: SupportedCurrencyKey; +}) { + try { + const cache = queryClient.getQueryCache(); + const cachedUserAssets = (cache.find(userAssetsQueryKey({ address, currency }))?.state?.data as ParsedAssetsDictByChain) || {}; + const retries = []; + for (const chainIdWithError of chainIds) { + retries.push( + fetchUserAssetsByChain( + { + address, + chainId: chainIdWithError, + currency, + }, + { cacheTime: 0 } + ) + ); + } + const parsedRetries = await Promise.all(retries); + for (const parsedAssets of parsedRetries) { + const values = Object.values(parsedAssets); + if (values[0]) { + cachedUserAssets[values[0].chainId] = parsedAssets; + } + } + queryClient.setQueryData(userAssetsQueryKey({ address, currency }), cachedUserAssets); + } catch (e) { + logger.error(new RainbowError('userAssetsQueryFunctionRetryByChain: '), { + message: (e as Error)?.message, + }); + } +} + +export async function parseUserAssets({ + assets, + chainIds, + currency, +}: { + assets: { + quantity: string; + small_balance?: boolean; + asset: ZerionAsset; + }[]; + chainIds: ChainId[]; + currency: SupportedCurrencyKey; +}) { + const parsedAssetsDict = chainIds.reduce((dict, currentChainId) => ({ ...dict, [currentChainId]: {} }), {}) as ParsedAssetsDictByChain; + for (const { asset, quantity, small_balance } of assets) { + if (!filterAsset(asset) && greaterThan(quantity, 0)) { + const parsedAsset = parseUserAsset({ + asset, + currency, + balance: quantity, + smallBalance: small_balance, + }); + parsedAssetsDict[parsedAsset?.chainId][parsedAsset.uniqueId] = parsedAsset; + } + } + + return parsedAssetsDict; +} + +// /////////////////////////////////////////////// +// Query Hook + +export function useUserAssets( + { address, currency }: UserAssetsArgs, + config: QueryConfigWithSelect = {} +) { + return useQuery(userAssetsQueryKey({ address, currency }), userAssetsQueryFunction, { + ...config, + refetchInterval: USER_ASSETS_REFETCH_INTERVAL, + staleTime: process.env.IS_TESTING === 'true' ? 0 : 1000, + }); +} diff --git a/src/__swaps__/screens/Swap/resources/assets/userAssetsByChain.ts b/src/__swaps__/screens/Swap/resources/assets/userAssetsByChain.ts new file mode 100644 index 00000000000..e9ff0ca8bbd --- /dev/null +++ b/src/__swaps__/screens/Swap/resources/assets/userAssetsByChain.ts @@ -0,0 +1,113 @@ +import { useQuery } from '@tanstack/react-query'; +import { Address } from 'viem'; + +import { QueryConfigWithSelect, QueryFunctionArgs, QueryFunctionResult, createQueryKey, queryClient } from '@/react-query'; +import { SupportedCurrencyKey } from '@/references'; +import { ParsedAssetsDictByChain, ParsedUserAsset } from '@/__swaps__/screens/Swap/types/assets'; +import { ChainId } from '@/__swaps__/screens/Swap/types/chains'; +import { AddressAssetsReceivedMessage } from '@/__swaps__/screens/Swap/types/refraction'; +import { RainbowError, logger } from '@/logger'; + +import { parseUserAssets, userAssetsQueryKey } from './userAssets'; +import { RainbowFetchClient } from '@/rainbow-fetch'; +import { ADDYS_API_KEY } from 'react-native-dotenv'; + +const USER_ASSETS_REFETCH_INTERVAL = 60000; + +const addysHttp = new RainbowFetchClient({ + baseURL: 'https://addys.p.rainbow.me/v3', + headers: { + Authorization: `Bearer ${ADDYS_API_KEY}`, + }, +}); + +// /////////////////////////////////////////////// +// Query Types + +export type UserAssetsByChainArgs = { + address: Address; + chainId: ChainId; + currency: SupportedCurrencyKey; +}; + +// /////////////////////////////////////////////// +// Query Key + +export const userAssetsByChainQueryKey = ({ address, chainId, currency }: UserAssetsByChainArgs) => + createQueryKey('userAssetsByChain', { address, chainId, currency }, { persisterVersion: 1 }); + +type UserAssetsByChainQueryKey = ReturnType; + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchUserAssetsByChain( + { address, chainId, currency }: UserAssetsByChainArgs, + config: QueryConfigWithSelect = {} +) { + return await queryClient.fetchQuery( + userAssetsByChainQueryKey({ + address, + chainId, + currency, + }), + userAssetsByChainQueryFunction, + config + ); +} + +// /////////////////////////////////////////////// +// Query Function + +export async function userAssetsByChainQueryFunction({ + queryKey: [{ address, chainId, currency }], +}: QueryFunctionArgs): Promise> { + const cache = queryClient.getQueryCache(); + const cachedUserAssets = (cache.find(userAssetsQueryKey({ address, currency }))?.state?.data || {}) as ParsedAssetsDictByChain; + const cachedDataForChain = cachedUserAssets?.[chainId]; + try { + const url = `/${chainId}/${address}/assets/?currency=${currency.toLowerCase()}`; + const res = await addysHttp.get(url); + const chainIdsInResponse = res?.data?.meta?.chain_ids || []; + const assets = res?.data?.payload?.assets || []; + if (assets.length && chainIdsInResponse.length) { + const parsedAssetsDict = await parseUserAssets({ + assets, + chainIds: chainIdsInResponse, + currency, + }); + + return parsedAssetsDict[chainId]; + } else { + return cachedDataForChain; + } + } catch (e) { + logger.error(new RainbowError(`userAssetsByChainQueryFunction - chainId = ${chainId}:`), { + message: (e as Error)?.message, + }); + return cachedDataForChain; + } +} + +type UserAssetsByChainResult = QueryFunctionResult; + +// /////////////////////////////////////////////// +// Query Hook + +export function useUserAssetsByChain( + { address, chainId, currency }: UserAssetsByChainArgs, + config: QueryConfigWithSelect = {} +) { + return useQuery( + userAssetsByChainQueryKey({ + address, + chainId, + currency, + }), + userAssetsByChainQueryFunction, + { + ...config, + refetchInterval: USER_ASSETS_REFETCH_INTERVAL, + } + ); +} diff --git a/src/__swaps__/screens/Swap/state/assetToBuy.ts b/src/__swaps__/screens/Swap/state/assetToBuy.ts new file mode 100644 index 00000000000..80041e805db --- /dev/null +++ b/src/__swaps__/screens/Swap/state/assetToBuy.ts @@ -0,0 +1,31 @@ +import { createStore } from '@/state/internal/createStore'; +import { useStore } from 'zustand'; +import { ParsedSearchAsset } from '../types/assets'; +import { SearchAsset } from '../types/search'; +import { ChainId } from '@/__swaps__/screens/Swap/types/chains'; + +export interface AssetToBuyState { + selectedAsset: ParsedSearchAsset | SearchAsset | undefined; + derivedChainId: ChainId | undefined; + setSelectedAsset: ({ asset }: { asset: ParsedSearchAsset | SearchAsset }) => void; + clearSelectedAsset: () => void; +} + +export const assetToBuyStore = createStore((set, get) => ({ + selectedAsset: undefined, + derivedChainId: undefined, + setSelectedAsset: ({ asset }) => { + const currentAsset = get().selectedAsset; + // prevent updating the asset to the same asset + if (currentAsset?.uniqueId === asset.uniqueId) { + return; + } + + set({ selectedAsset: asset, derivedChainId: asset.chainId }); + }, + clearSelectedAsset: () => { + set({ selectedAsset: undefined, derivedChainId: undefined }); + }, +})); + +export const useAssetToBuyStore = () => useStore(assetToBuyStore); diff --git a/src/__swaps__/screens/Swap/state/assetToSell.ts b/src/__swaps__/screens/Swap/state/assetToSell.ts new file mode 100644 index 00000000000..c59a055aa0b --- /dev/null +++ b/src/__swaps__/screens/Swap/state/assetToSell.ts @@ -0,0 +1,32 @@ +import { createStore } from '@/state/internal/createStore'; +import { useStore } from 'zustand'; +import { ParsedSearchAsset } from '../types/assets'; +import { SearchAsset } from '../types/search'; +import { ChainId } from '@/__swaps__/screens/Swap/types/chains'; + +export interface AssetToSellState { + selectedAsset: ParsedSearchAsset | SearchAsset; + derivedChainId: ChainId | undefined; + setSelectedAsset: ({ asset }: { asset: ParsedSearchAsset | SearchAsset }) => void; + clearSelectedAsset: () => void; +} + +export const assetToSellStore = (initialAsset: ParsedSearchAsset | SearchAsset) => + createStore((set, get) => ({ + selectedAsset: initialAsset, + derivedChainId: initialAsset.chainId, + setSelectedAsset: ({ asset }) => { + const currentAsset = get().selectedAsset; + // prevent updating the asset to the same asset + if (currentAsset?.uniqueId === asset.uniqueId) { + return; + } + + set({ selectedAsset: asset, derivedChainId: asset.chainId }); + }, + clearSelectedAsset: () => { + set({ selectedAsset: undefined, derivedChainId: ChainId.mainnet }); + }, + })); + +export const useAssetToSellStore = (initialAsset: ParsedSearchAsset | SearchAsset) => useStore(assetToSellStore(initialAsset)); diff --git a/src/__swaps__/screens/Swap/state/settings.ts b/src/__swaps__/screens/Swap/state/settings.ts new file mode 100644 index 00000000000..3af49915367 --- /dev/null +++ b/src/__swaps__/screens/Swap/state/settings.ts @@ -0,0 +1,94 @@ +import { createStore } from '@/state/internal/createStore'; +import { Source } from '@rainbow-me/swaps'; +import { useStore } from 'zustand'; +import { ChainId, ChainName } from '../types/chains'; +import { chainNameFromChainId } from '../utils/chains'; +import { RainbowConfig } from '@/model/remoteConfig'; + +export interface SwapSettingsState { + aggregator: Source | 'auto'; + slippage: string; + flashbots: boolean; +} + +interface SettingsStoreProps { + chainId: ChainId; + config: RainbowConfig; +} + +export const swapSettingsStore = ({ chainId, config }: SettingsStoreProps) => + createStore( + set => ({ + aggregator: 'auto', + slippage: getDefaultSlippage(chainId, config), + flashbots: false, + setSlippage: (slippage: string) => { + const slippageValue = parseInt(slippage, 10); + if (slippageValue >= 1 && slippageValue <= 99) { + set({ slippage }); + } else { + console.error('Slippage value must be between 1 and 99'); + } + }, + toggleFlashbots: () => { + set(state => ({ flashbots: !state.flashbots })); + }, + setAggregator: (aggregator: Source | 'auto') => { + set({ aggregator }); + }, + }), + { + persist: { + name: 'SwapSettings', + version: 1, + }, + } + ); + +export const useSwapSettingsStore = ({ chainId, config }: SettingsStoreProps) => + useStore( + swapSettingsStore({ + chainId, + config, + }) + ); + +export const DEFAULT_SLIPPAGE_BIPS = { + [ChainId.mainnet]: 100, + [ChainId.polygon]: 200, + [ChainId.bsc]: 200, + [ChainId.optimism]: 200, + [ChainId.base]: 200, + [ChainId.zora]: 200, + [ChainId.arbitrum]: 200, + [ChainId.avalanche]: 200, +}; + +export const DEFAULT_SLIPPAGE = { + [ChainId.mainnet]: '1', + [ChainId.polygon]: '2', + [ChainId.bsc]: '2', + [ChainId.optimism]: '2', + [ChainId.base]: '2', + [ChainId.zora]: '2', + [ChainId.arbitrum]: '2', + [ChainId.avalanche]: '2', +}; + +const slippageInBipsToString = (slippageInBips: number) => (slippageInBips / 100).toString(); + +export const getDefaultSlippage = (chainId: ChainId, config: RainbowConfig) => { + const chainName = chainNameFromChainId(chainId) as + | ChainName.mainnet + | ChainName.optimism + | ChainName.polygon + | ChainName.arbitrum + | ChainName.base + | ChainName.zora + | ChainName.bsc + | ChainName.avalanche; + return slippageInBipsToString( + // NOTE: JSON.parse doesn't type the result as a Record + (config.default_slippage_bips as unknown as Record)[chainName] || DEFAULT_SLIPPAGE_BIPS[chainId] + ); +}; diff --git a/src/__swaps__/screens/Swap/state/tokenSearch.ts b/src/__swaps__/screens/Swap/state/tokenSearch.ts new file mode 100644 index 00000000000..442b5294289 --- /dev/null +++ b/src/__swaps__/screens/Swap/state/tokenSearch.ts @@ -0,0 +1,15 @@ +import { createStore } from '@/state/internal/createStore'; +import { useStore } from 'zustand'; + +export interface TokenSearchState { + searchQuery: string; +} + +export const tokenSearchStore = createStore(set => ({ + searchQuery: '', + setSearchQuery: (query: string) => { + set({ searchQuery: query }); + }, +})); + +export const useTokenSearchStore = () => useStore(tokenSearchStore); diff --git a/src/__swaps__/screens/Swap/types/refraction.ts b/src/__swaps__/screens/Swap/types/refraction.ts new file mode 100644 index 00000000000..a653dea3eee --- /dev/null +++ b/src/__swaps__/screens/Swap/types/refraction.ts @@ -0,0 +1,54 @@ +import { ZerionAsset } from './assets'; +import { ChainId, ChainName } from './chains'; +import { PaginatedTransactionsApiResponse } from '@/resources/transactions/types'; + +/** + * Metadata for a message from the Zerion API. + */ +export interface MessageMeta { + address?: string; + currency?: string; + cut_off?: number; + status?: string; + chain_id?: ChainName; // L2 + chain_ids?: ChainId[]; // v3 consolidated + chain_ids_with_errors?: ChainId[]; // v3 consolidated + asset_codes?: string; + next_page_cursor?: string; +} + +/** + * A message from the Zerion API indicating that assets were received. + */ +export interface AddressAssetsReceivedMessage { + payload?: { + assets?: { + asset: ZerionAsset; + quantity: string; + small_balances?: boolean; + }[]; + }; + meta?: MessageMeta; +} + +/** + * A message from the Zerion API indicating that transaction data was received. + */ +export interface TransactionsReceivedMessage { + payload?: { + transactions?: PaginatedTransactionsApiResponse[]; + }; + meta?: MessageMeta; +} + +/** + * A message from the Zerion API indicating that asset price data was received + */ +export interface AssetPricesReceivedMessage { + payload?: { + prices?: { + [id: string]: ZerionAsset; + }; + }; + meta?: MessageMeta; +} diff --git a/src/__swaps__/screens/Swap/types/swap.ts b/src/__swaps__/screens/Swap/types/swap.ts index 7325698c2bd..41b33bf5c59 100644 --- a/src/__swaps__/screens/Swap/types/swap.ts +++ b/src/__swaps__/screens/Swap/types/swap.ts @@ -1,2 +1,3 @@ export type inputKeys = 'inputAmount' | 'inputNativeValue' | 'outputAmount' | 'outputNativeValue'; export type inputMethods = inputKeys | 'slider'; +export type SortMethod = 'token' | 'chain'; diff --git a/src/__swaps__/screens/Swap/utils/address.ts b/src/__swaps__/screens/Swap/utils/address.ts new file mode 100644 index 00000000000..db36ec100f3 --- /dev/null +++ b/src/__swaps__/screens/Swap/utils/address.ts @@ -0,0 +1,19 @@ +import { Address } from 'viem'; + +import { AddressOrEth, UniqueId } from '../types/assets'; +import { ChainId } from '../types/chains'; + +export function truncateAddress(address?: AddressOrEth) { + if (!address) return ''; + return `${address?.slice(0, 6)}…${address?.slice(-4)}`; +} + +export function deriveAddressAndChainWithUniqueId(uniqueId: UniqueId) { + const fragments = uniqueId.split('_'); + const address = fragments[0] as Address; + const chain = parseInt(fragments[1], 10) as ChainId; + return { + address, + chain, + }; +} diff --git a/src/__swaps__/screens/Swap/utils/swaps.ts b/src/__swaps__/screens/Swap/utils/swaps.ts index 171458fd743..b9f20ccbee4 100644 --- a/src/__swaps__/screens/Swap/utils/swaps.ts +++ b/src/__swaps__/screens/Swap/utils/swaps.ts @@ -1,6 +1,7 @@ import c from 'chroma-js'; import { globalColors } from '@/design-system'; import { SCRUBBER_WIDTH, SLIDER_WIDTH } from '../constants'; +import { Source } from '@rainbow-me/swaps'; // /---- 🎨 Color functions 🎨 ----/ // // @@ -207,3 +208,14 @@ export function niceIncrementFormatter( } // // /---- END worklet utils ----/ // + +import Logo0x from '@/__swaps__/assets/aggregators/0x.png'; +import Logo1Inch from '@/__swaps__/assets/aggregators/1inch.png'; +import LogoRainbow from '@/__swaps__/assets/aggregators/rainbow.png'; +import * as i18n from '@/languages'; + +export const aggregatorInfo = { + auto: { logo: LogoRainbow, name: i18n.t(i18n.l.swap.aggregators.rainbow) }, + [Source.Aggregator0x]: { logo: Logo0x, name: Source.Aggregator0x }, + [Source.Aggregotor1inch]: { logo: Logo1Inch, name: Source.Aggregotor1inch }, +}; diff --git a/src/__swaps__/screens/Swap/utils/userChains.ts b/src/__swaps__/screens/Swap/utils/userChains.ts new file mode 100644 index 00000000000..ecec27fab44 --- /dev/null +++ b/src/__swaps__/screens/Swap/utils/userChains.ts @@ -0,0 +1,74 @@ +import { + Chain, + arbitrum, + arbitrumGoerli, + arbitrumSepolia, + avalanche, + avalancheFuji, + base, + baseSepolia, + bsc, + bscTestnet, + holesky, + optimism, + optimismSepolia, + polygon, + polygonMumbai, + zora, + zoraSepolia, + goerli, + mainnet, + sepolia, +} from 'viem/chains'; + +import { ChainId, ChainNameDisplay } from '../types/chains'; + +export const chainIdMap: Record< + ChainId.mainnet | ChainId.optimism | ChainId.polygon | ChainId.base | ChainId.bsc | ChainId.zora | ChainId.avalanche, + ChainId[] +> = { + [ChainId.mainnet]: [mainnet.id, goerli.id, sepolia.id, holesky.id], + [ChainId.optimism]: [optimism.id, optimismSepolia.id], + [ChainId.arbitrum]: [arbitrum.id, arbitrumGoerli.id, arbitrumSepolia.id], + [ChainId.polygon]: [polygon.id, polygonMumbai.id], + [ChainId.base]: [base.id, baseSepolia.id], + [ChainId.bsc]: [bsc.id, bscTestnet.id], + [ChainId.zora]: [zora.id, zoraSepolia.id], + [ChainId.avalanche]: [avalanche.id, avalancheFuji.id], +}; + +export const chainLabelMap: Record< + ChainId.mainnet | ChainId.optimism | ChainId.polygon | ChainId.base | ChainId.bsc | ChainId.zora | ChainId.avalanche, + string[] +> = { + [ChainId.mainnet]: [ChainNameDisplay[goerli.id], ChainNameDisplay[sepolia.id], ChainNameDisplay[holesky.id]], + [ChainId.optimism]: [ChainNameDisplay[optimismSepolia.id]], + [ChainId.arbitrum]: [ChainNameDisplay[arbitrumGoerli.id], ChainNameDisplay[arbitrumSepolia.id]], + [ChainId.polygon]: [ChainNameDisplay[polygonMumbai.id]], + [ChainId.base]: [ChainNameDisplay[baseSepolia.id]], + [ChainId.bsc]: [ChainNameDisplay[bscTestnet.id]], + [ChainId.zora]: [ChainNameDisplay[zoraSepolia.id]], + [ChainId.avalanche]: [ChainNameDisplay[avalancheFuji.id]], +}; + +export const sortNetworks = (order: ChainId[], chains: Chain[]) => { + const allChainsOrder = order?.map(chainId => chainIdMap[chainId] || [chainId])?.flat(); + const ordered = chains.sort((a, b) => { + const aIndex = allChainsOrder.indexOf(a.id); + const bIndex = allChainsOrder.indexOf(b.id); + if (aIndex === -1) return bIndex === -1 ? 0 : 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + }); + return ordered; +}; + +export const filterUserNetworks = ({ userChains }: { userChains: Record }) => { + const availableChains = Object.keys(userChains) + .filter(chainId => userChains[Number(chainId)] === true) + .map(chainId => Number(chainId)); + + const allAvailableUserChains = availableChains.map(chainId => chainIdMap[chainId]).flat(); + + return allAvailableUserChains; +}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 59ed5591d41..b3ababa3bbd 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -1,3 +1,5 @@ +import gql from 'graphql-tag'; + import { config } from './config'; import { getFetchRequester } from './utils/getFetchRequester'; import { getSdk as getEnsSdk } from './__generated__/ens'; @@ -5,8 +7,19 @@ import { getSdk as getMetadataSdk } from './__generated__/metadata'; import { getSdk as getArcSdk } from './__generated__/arc'; import { getSdk as getArcDevSdk } from './__generated__/arcDev'; import { IS_PROD } from '@/env'; +import { RainbowFetchRequestOpts } from '@/rainbow-fetch'; + +export const metadataRequester = getFetchRequester(config.metadata); export const ensClient = getEnsSdk(getFetchRequester(config.ens)); -export const metadataClient = getMetadataSdk(getFetchRequester(config.metadata)); +export const metadataClient = getMetadataSdk(metadataRequester); export const metadataPOSTClient = getMetadataSdk(getFetchRequester(config.metadataPOST)); export const arcClient = IS_PROD ? getArcSdk(getFetchRequester(config.arc)) : getArcDevSdk(getFetchRequester(config.arcDev)); + +export const requestMetadata = (q: string, options?: Pick) => + metadataRequester( + gql` + ${q} + `, + options || {} + ); diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 39843e734e9..5f9057441c9 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -1811,6 +1811,9 @@ "too_many_signup_request": "Too many signup requests, please try again later" }, "swap": { + "aggregators": { + "rainbow": "Rainbow" + }, "choose_token": "Choose Token", "gas": { "custom": "Custom", diff --git a/src/model/remoteConfig.ts b/src/model/remoteConfig.ts index 16f40467f4e..c02e09c31b5 100644 --- a/src/model/remoteConfig.ts +++ b/src/model/remoteConfig.ts @@ -25,7 +25,7 @@ import { getNetwork, saveNetwork } from '@/handlers/localstorage/globalSettings' import { web3SetHttpProvider } from '@/handlers/web3'; import { delay } from '@/utils/delay'; -interface RainbowConfig extends Record { +export interface RainbowConfig extends Record { arbitrum_mainnet_rpc: string; bsc_mainnet_rpc: string; data_api_key: string; @@ -85,7 +85,7 @@ interface RainbowConfig extends Record { swaps_v2: boolean; } -const DEFAULT_CONFIG: RainbowConfig = { +export const DEFAULT_CONFIG: RainbowConfig = { arbitrum_mainnet_rpc: ARBITRUM_MAINNET_RPC, data_api_key: DATA_API_KEY, data_endpoint: DATA_ENDPOINT || 'wss://api-v4.zerion.io', diff --git a/src/references/index.ts b/src/references/index.ts index 73370090dbe..8ab12a493ad 100644 --- a/src/references/index.ts +++ b/src/references/index.ts @@ -1,5 +1,29 @@ +import { ChainNameDisplay } from '@/__swaps__/screens/Swap/types/chains'; import { Asset } from '@/entities'; import { Network } from '@/helpers/networkTypes'; +import { + Chain, + arbitrum, + arbitrumGoerli, + arbitrumSepolia, + avalanche, + avalancheFuji, + base, + baseSepolia, + bsc, + bscTestnet, + goerli, + holesky, + mainnet, + optimism, + optimismSepolia, + polygon, + polygonMumbai, + zora, + zoraSepolia, + sepolia, + blast, +} from 'viem/chains'; export { default as balanceCheckerContractAbi } from './balances-checker-abi.json'; export { default as chainAssets } from './chain-assets.json'; @@ -128,3 +152,33 @@ export const AddCashCurrencyInfo: { }; export const REFERRER = 'native-app'; + +export const SUPPORTED_MAINNET_CHAINS: Chain[] = [mainnet, polygon, optimism, arbitrum, base, zora, bsc, avalanche, blast].map(chain => ({ + ...chain, + name: ChainNameDisplay[chain.id], +})); + +export const SUPPORTED_CHAINS: Chain[] = [ + mainnet, + polygon, + optimism, + arbitrum, + holesky, + base, + zora, + bsc, + goerli, + sepolia, + optimismSepolia, + bscTestnet, + polygonMumbai, + arbitrumGoerli, + arbitrumSepolia, + baseSepolia, + zoraSepolia, + avalanche, + avalancheFuji, + blast, +].map(chain => ({ ...chain, name: ChainNameDisplay[chain.id] })); + +export const SUPPORTED_CHAIN_IDS = SUPPORTED_CHAINS.map(chain => chain.id); diff --git a/src/resources/assets/assetSelectors.ts b/src/resources/assets/assetSelectors.ts index db8a15c9c92..380c7e420cc 100644 --- a/src/resources/assets/assetSelectors.ts +++ b/src/resources/assets/assetSelectors.ts @@ -3,6 +3,7 @@ import isEmpty from 'lodash/isEmpty'; import isNil from 'lodash/isNil'; import { ParsedAddressAsset } from '@/entities'; import { parseAssetsNative } from '@/parsers'; +import { ChainId } from '@rainbow-me/swaps'; const EMPTY_ARRAY: any = [];