From 3287f0983aaef0acc85b82de3d7dc472cf8b6cdf Mon Sep 17 00:00:00 2001 From: Christopher Howard Date: Thu, 3 Aug 2023 13:35:48 -0500 Subject: [PATCH] [BX-915] Consolidated Transaction History (#816) Co-authored-by: gregs --- src/core/network/addys.ts | 2 +- src/core/network/index.ts | 5 +- src/core/network/refractionAddressWs.ts | 9 - src/core/react-query/index.ts | 1 + src/core/react-query/types.ts | 20 ++ src/core/resources/_selectors/transactions.ts | 4 +- src/core/resources/assets/userAssets.ts | 225 +++++++++++++---- .../resources/assets/userAssetsByChain.ts | 144 ++++------- .../transactions/consolidatedTransactions.ts | 165 +++++++++++++ .../resources/transactions/registryLookup.ts | 2 +- .../resources/transactions/transactions.ts | 64 ++--- src/core/types/refraction.ts | 13 +- src/core/types/transactions.ts | 3 +- src/core/utils/chains.ts | 2 + src/core/utils/txWatcher.ts | 109 --------- src/entries/popup/hooks/useAllTransactions.ts | 223 ----------------- .../popup/hooks/useInfiniteTransactionList.ts | 231 ++++++++++++++++++ .../hooks/useWatchPendingTransactions.ts | 60 ++++- src/entries/popup/pages/home/Activity.tsx | 63 +++-- 19 files changed, 762 insertions(+), 583 deletions(-) create mode 100644 src/core/resources/transactions/consolidatedTransactions.ts delete mode 100644 src/core/utils/txWatcher.ts delete mode 100644 src/entries/popup/hooks/useAllTransactions.ts create mode 100644 src/entries/popup/hooks/useInfiniteTransactionList.ts diff --git a/src/core/network/addys.ts b/src/core/network/addys.ts index dc5598772c..2a3f63e911 100644 --- a/src/core/network/addys.ts +++ b/src/core/network/addys.ts @@ -1,6 +1,6 @@ import { createHttpClient } from './internal/createHttpClient'; export const addysHttp = createHttpClient({ - baseUrl: 'https://addys.p.rainbow.me/v2', + baseUrl: 'https://addys.p.rainbow.me/v3', headers: { Authorization: `Bearer ${process.env.ADDYS_API_KEY}` as string }, }); diff --git a/src/core/network/index.ts b/src/core/network/index.ts index 80e5c965e5..29fd47ff2d 100644 --- a/src/core/network/index.ts +++ b/src/core/network/index.ts @@ -1,9 +1,6 @@ export { etherscanHttp } from './etherscan'; export { meteorologyHttp } from './meteorology'; -export { - refractionAddressWs, - refractionAddressMessages, -} from './refractionAddressWs'; +export { refractionAddressMessages } from './refractionAddressWs'; export { refractionAssetsWs, refractionAssetsMessages, diff --git a/src/core/network/refractionAddressWs.ts b/src/core/network/refractionAddressWs.ts index c8d32c11b5..8f0a67909f 100644 --- a/src/core/network/refractionAddressWs.ts +++ b/src/core/network/refractionAddressWs.ts @@ -1,5 +1,3 @@ -import { createWebSocketClient } from '~/core/network/internal/createWebSocketClient'; - export const refractionAddressMessages = { ADDRESS_ASSETS: { APPENDED: 'appended address assets', @@ -20,10 +18,3 @@ export const refractionAddressMessages = { REMOVED: 'removed address transactions', }, }; - -export const refractionAddressWs = createWebSocketClient({ - baseUrl: `${process.env.DATA_ENDPOINT}/address`, - query: { - api_token: process.env.DATA_API_KEY, - }, -}); diff --git a/src/core/react-query/index.ts b/src/core/react-query/index.ts index 2b3e9ae5c8..0cc73bc8ea 100644 --- a/src/core/react-query/index.ts +++ b/src/core/react-query/index.ts @@ -5,6 +5,7 @@ export { persistOptions, queryClient } from './queryClient'; export type { MutationConfig, MutationFunctionResult, + InfiniteQueryConfig, QueryConfig, QueryFunctionArgs, QueryFunctionResult, diff --git a/src/core/react-query/types.ts b/src/core/react-query/types.ts index 8f9507b7a3..50604bd54a 100644 --- a/src/core/react-query/types.ts +++ b/src/core/react-query/types.ts @@ -3,6 +3,7 @@ import { QueryFunctionContext, QueryKey, + UseInfiniteQueryOptions, UseMutationOptions, UseQueryOptions, } from '@tanstack/react-query'; @@ -41,6 +42,25 @@ export type QueryConfig< | 'onSuccess' >; +export type InfiniteQueryConfig = Pick< + UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + Array + >, + | 'cacheTime' + | 'enabled' + | 'refetchInterval' + | 'retry' + | 'staleTime' + | 'select' + | 'onError' + | 'onSettled' + | 'onSuccess' +>; + export type MutationConfig = Pick< UseMutationOptions, 'onError' | 'onMutate' | 'onSettled' | 'onSuccess' diff --git a/src/core/resources/_selectors/transactions.ts b/src/core/resources/_selectors/transactions.ts index e8df365705..39ed6f36d1 100644 --- a/src/core/resources/_selectors/transactions.ts +++ b/src/core/resources/_selectors/transactions.ts @@ -8,8 +8,8 @@ export const selectTransactionsByDate = ( ) => { const sortedTransactions = transactions.sort((tx1, tx2) => { if (tx1.pending && tx2.pending) return (tx2.nonce || 0) - (tx1.nonce || 0); - if (tx1.pending || tx2.pending) return -1; - + if (tx1.pending) return -1; + if (tx2.pending) return 1; if (!tx1.minedAt) return -1; if (!tx2.minedAt) return 1; diff --git a/src/core/resources/assets/userAssets.ts b/src/core/resources/assets/userAssets.ts index abf4f7876d..0a10affca9 100644 --- a/src/core/resources/assets/userAssets.ts +++ b/src/core/resources/assets/userAssets.ts @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query'; +import { getProvider } from '@wagmi/core'; import { Address } from 'wagmi'; +import { addysHttp } from '~/core/network/addys'; import { QueryConfig, QueryFunctionArgs, @@ -9,21 +11,32 @@ import { queryClient, } from '~/core/react-query'; import { SupportedCurrencyKey } from '~/core/references'; -import { ParsedAssetsDictByChain } from '~/core/types/assets'; -import { ChainName } from '~/core/types/chains'; -import { chainIdFromChainName } from '~/core/utils/chains'; +import { + ParsedAddressAsset, + ParsedAssetsDictByChain, + ZerionAsset, +} from '~/core/types/assets'; +import { ChainId } from '~/core/types/chains'; +import { AddressAssetsReceivedMessage } from '~/core/types/refraction'; +import { + fetchAssetBalanceViaProvider, + filterAsset, + parseAddressAsset, +} from '~/core/utils/assets'; +import { SUPPORTED_CHAIN_IDS } from '~/core/utils/chains'; +import { greaterThan } from '~/core/utils/numbers'; +import { RainbowError, logger } from '~/logger'; +import { + DAI_MAINNET_ASSET, + ETH_MAINNET_ASSET, + USDC_MAINNET_ASSET, +} from '~/test/utils'; import { fetchUserAssetsByChain } from './userAssetsByChain'; const USER_ASSETS_REFETCH_INTERVAL = 60000; +const USER_ASSETS_TIMEOUT_DURATION = 20000; export const USER_ASSETS_STALE_INTERVAL = 30000; -const REFRACTION_SUPPORTED_CHAINS = [ - ChainName.mainnet, - ChainName.optimism, - ChainName.polygon, - ChainName.arbitrum, - ChainName.bsc, -]; // /////////////////////////////////////////////// // Query Types @@ -108,50 +121,173 @@ export const userAssetsSetQueryData = ({ ); }; -async function userAssetsQueryFunctionByChain({ - address, - currency, - connectedToHardhat, -}: UserAssetsArgs) { +async function userAssetsQueryFunction({ + queryKey: [{ address, currency, connectedToHardhat }], +}: QueryFunctionArgs) { const cache = queryClient.getQueryCache(); - const cachedUserAssets = cache.find( + const cachedUserAssets = (cache.find( userAssetsQueryKey({ address, currency, connectedToHardhat }), - )?.state?.data as ParsedAssetsDictByChain; - const getResultsForChain = async (chain: ChainName) => { - const results = - (await fetchUserAssetsByChain( - { address, chain, currency, connectedToHardhat }, - { cacheTime: 0 }, - )) || {}; - const chainId = chainIdFromChainName(chain); - const cachedDataForChain = cachedUserAssets?.[chainId] || {}; - return { - [chainId]: - results && Object.keys(results).length ? results : cachedDataForChain, - }; - }; - const queries = REFRACTION_SUPPORTED_CHAINS.map((chain) => - getResultsForChain(chain), - ); + )?.state?.data || {}) as ParsedAssetsDictByChain; try { - const results = await Promise.all(queries); - return Object.assign({}, ...results) as ParsedAssetsDictByChain; + 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, + connectedToHardhat, + currency, + }); + if (assets.length && chainIdsInResponse.length) { + const parsedAssetsDict = await parseUserAssets({ + address, + assets, + chainIds: chainIdsInResponse, + connectedToHardhat, + currency, + }); + + return parsedAssetsDict; + } + } + return cachedUserAssets; } catch (e) { + logger.error(new RainbowError('userAssetsQueryFunction: '), { + message: (e as Error)?.message, + }); return cachedUserAssets; } } -async function userAssetsQueryFunction({ - queryKey: [{ address, currency, connectedToHardhat }], -}: QueryFunctionArgs) { - return await userAssetsQueryFunctionByChain({ - address, - currency, - connectedToHardhat, - }); +type UserAssetsResult = QueryFunctionResult; + +async function userAssetsQueryFunctionRetryByChain({ + address, + chainIds, + connectedToHardhat, + currency, +}: { + address: Address; + chainIds: ChainId[]; + connectedToHardhat: boolean; + currency: SupportedCurrencyKey; +}) { + try { + const cache = queryClient.getQueryCache(); + const cachedUserAssets = + (cache.find(userAssetsQueryKey({ address, currency, connectedToHardhat })) + ?.state?.data as ParsedAssetsDictByChain) || {}; + const retries = []; + for (const chainIdWithError of chainIds) { + retries.push( + fetchUserAssetsByChain( + { + address, + chainId: chainIdWithError, + connectedToHardhat, + 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, connectedToHardhat, currency }), + cachedUserAssets, + ); + } catch (e) { + logger.error(new RainbowError('userAssetsQueryFunctionRetryByChain: '), { + message: (e as Error)?.message, + }); + } } -type UserAssetsResult = QueryFunctionResult; +export async function parseUserAssets({ + address, + assets, + chainIds, + connectedToHardhat, + currency, +}: { + address: Address; + assets: { + quantity: string; + asset: ZerionAsset; + }[]; + chainIds: ChainId[]; + connectedToHardhat: boolean; + currency: SupportedCurrencyKey; +}) { + const parsedAssetsDict = chainIds.reduce( + (dict, currentChainId) => ({ ...dict, [currentChainId]: {} }), + {}, + ) as ParsedAssetsDictByChain; + for (const { asset, quantity } of assets) { + if (!filterAsset(asset) && greaterThan(quantity, 0)) { + const parsedAsset = parseAddressAsset({ + address: asset?.asset_code, + asset, + currency, + quantity, + }); + parsedAssetsDict[parsedAsset?.chainId][parsedAsset.uniqueId] = + parsedAsset; + } + } + if (connectedToHardhat) { + const provider = getProvider({ chainId: ChainId.hardhat }); + // force checking for ETH if connected to hardhat + const mainnetAssets = parsedAssetsDict[ChainId.mainnet]; + mainnetAssets[ETH_MAINNET_ASSET.uniqueId] = ETH_MAINNET_ASSET; + if (process.env.IS_TESTING === 'true') { + mainnetAssets[USDC_MAINNET_ASSET.uniqueId] = USDC_MAINNET_ASSET; + mainnetAssets[DAI_MAINNET_ASSET.uniqueId] = DAI_MAINNET_ASSET; + } + const mainnetBalanceRequests = Object.values(mainnetAssets).map( + async (parsedAsset) => { + if (parsedAsset.chainId !== ChainId.mainnet) return parsedAsset; + try { + const _parsedAsset = await fetchAssetBalanceViaProvider({ + parsedAsset, + currentAddress: address, + currency, + provider, + }); + return _parsedAsset; + } catch (e) { + return parsedAsset; + } + }, + ); + const newParsedMainnetAssetsByUniqueId = await Promise.all( + mainnetBalanceRequests, + ); + const newMainnetAssets = newParsedMainnetAssetsByUniqueId.reduce< + Record + >((acc, parsedAsset) => { + acc[parsedAsset.uniqueId] = parsedAsset; + return acc; + }, {}); + parsedAssetsDict[ChainId.mainnet] = newMainnetAssets; + } + return parsedAssetsDict; +} // /////////////////////////////////////////////// // Query Hook @@ -171,6 +307,7 @@ export function useUserAssets( { ...config, refetchInterval: USER_ASSETS_REFETCH_INTERVAL, + staleTime: USER_ASSETS_REFETCH_INTERVAL, }, ); } diff --git a/src/core/resources/assets/userAssetsByChain.ts b/src/core/resources/assets/userAssetsByChain.ts index 14718250df..63d8d4a95b 100644 --- a/src/core/resources/assets/userAssetsByChain.ts +++ b/src/core/resources/assets/userAssetsByChain.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query'; -import { getProvider } from '@wagmi/core'; import { Address } from 'wagmi'; import { addysHttp } from '~/core/network/addys'; @@ -11,29 +10,15 @@ import { queryClient, } from '~/core/react-query'; import { SupportedCurrencyKey } from '~/core/references'; -import { currentAddressStore } from '~/core/state'; import { ParsedAddressAsset, ParsedAssetsDictByChain, } from '~/core/types/assets'; -import { ChainId, ChainName } from '~/core/types/chains'; +import { ChainId } from '~/core/types/chains'; import { AddressAssetsReceivedMessage } from '~/core/types/refraction'; -import { - fetchAssetBalanceViaProvider, - filterAsset, - parseAddressAsset, -} from '~/core/utils/assets'; -import { chainIdFromChainName } from '~/core/utils/chains'; -import { greaterThan } from '~/core/utils/numbers'; -import { isLowerCaseMatch } from '~/core/utils/strings'; import { RainbowError, logger } from '~/logger'; -import { - DAI_MAINNET_ASSET, - ETH_MAINNET_ASSET, - USDC_MAINNET_ASSET, -} from '~/test/utils'; -import { userAssetsQueryKey } from './userAssets'; +import { parseUserAssets, userAssetsQueryKey } from './userAssets'; const USER_ASSETS_REFETCH_INTERVAL = 60000; @@ -41,8 +26,8 @@ const USER_ASSETS_REFETCH_INTERVAL = 60000; // Query Types export type UserAssetsByChainArgs = { - address?: Address; - chain: ChainName; + address: Address; + chainId: ChainId; currency: SupportedCurrencyKey; connectedToHardhat: boolean; }; @@ -52,13 +37,13 @@ export type UserAssetsByChainArgs = { export const userAssetsByChainQueryKey = ({ address, - chain, + chainId, currency, connectedToHardhat, }: UserAssetsByChainArgs) => createQueryKey( 'userAssetsByChain', - { address, chain, currency, connectedToHardhat }, + { address, chainId, currency, connectedToHardhat }, { persisterVersion: 1 }, ); @@ -70,7 +55,7 @@ type UserAssetsByChainQueryKey = ReturnType; export async function fetchUserAssetsByChain< TSelectData = UserAssetsByChainResult, >( - { address, chain, currency, connectedToHardhat }: UserAssetsByChainArgs, + { address, chainId, currency, connectedToHardhat }: UserAssetsByChainArgs, config: QueryConfig< UserAssetsByChainResult, Error, @@ -79,7 +64,12 @@ export async function fetchUserAssetsByChain< > = {}, ) { return await queryClient.fetchQuery( - userAssetsByChainQueryKey({ address, chain, currency, connectedToHardhat }), + userAssetsByChainQueryKey({ + address, + chainId, + currency, + connectedToHardhat, + }), userAssetsByChainQueryFunction, config, ); @@ -89,72 +79,43 @@ export async function fetchUserAssetsByChain< // Query Function export async function userAssetsByChainQueryFunction({ - queryKey: [{ address, chain, currency, connectedToHardhat }], + queryKey: [{ address, chainId, currency, connectedToHardhat }], }: QueryFunctionArgs): Promise< Record > { + const cache = queryClient.getQueryCache(); + const cachedUserAssets = (cache.find( + userAssetsQueryKey({ address, currency, connectedToHardhat }), + )?.state?.data || {}) as ParsedAssetsDictByChain; + const cachedDataForChain = cachedUserAssets?.[chainId]; try { - const { currentAddress } = currentAddressStore.getState(); - const chainId = chainIdFromChainName(chain); const url = `/${chainId}/${address}/assets/?currency=${currency.toLowerCase()}`; - const response = await addysHttp.get(url); - const data = response.data; - const parsedUserAssetsByUniqueId = parseUserAssetsByChain(data, currency); - - if (connectedToHardhat && chain === ChainName.mainnet) { - const provider = getProvider({ chainId: ChainId.hardhat }); - // force checking for ETH if connected to hardhat - parsedUserAssetsByUniqueId[ETH_MAINNET_ASSET.uniqueId] = - ETH_MAINNET_ASSET; - if (process.env.IS_TESTING === 'true') { - parsedUserAssetsByUniqueId[USDC_MAINNET_ASSET.uniqueId] = - USDC_MAINNET_ASSET; - parsedUserAssetsByUniqueId[DAI_MAINNET_ASSET.uniqueId] = - DAI_MAINNET_ASSET; - } - const parsePromises = Object.values(parsedUserAssetsByUniqueId).map( - async (parsedAsset) => { - if (parsedAsset.chainId !== ChainId.mainnet) return parsedAsset; - try { - const _parsedAsset = await fetchAssetBalanceViaProvider({ - parsedAsset, - currentAddress, - currency, - provider, - }); - return _parsedAsset; - } catch (e) { - return parsedAsset; - } - }, - ); - const newParsedUserAssetsByUniqueId = await Promise.all(parsePromises); - return newParsedUserAssetsByUniqueId.reduce< - Record - >((acc, parsedAsset) => { - acc[parsedAsset.uniqueId] = parsedAsset; - return acc; - }, {}); + 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({ + address, + assets, + chainIds: chainIdsInResponse, + connectedToHardhat, + currency, + }); + + return parsedAssetsDict[chainId]; } else { - if (isLowerCaseMatch(data?.meta?.address, address)) { - return parsedUserAssetsByUniqueId; - } else { - return {}; - } + return cachedDataForChain; } } catch (e) { logger.error( - new RainbowError(`userAssetsByChainQueryFunction - chain = ${chain}:`), + new RainbowError( + `userAssetsByChainQueryFunction - chainId = ${chainId}:`, + ), { message: (e as Error)?.message, }, ); - const cache = queryClient.getQueryCache(); - const cachedUserAssets = cache.find( - userAssetsQueryKey({ address, currency, connectedToHardhat }), - )?.state?.data as ParsedAssetsDictByChain; - const cachedDataForChain = cachedUserAssets?.[chainIdFromChainName(chain)]; - return (cachedDataForChain as Record) || {}; + return cachedDataForChain; } } @@ -162,33 +123,11 @@ type UserAssetsByChainResult = QueryFunctionResult< typeof userAssetsByChainQueryFunction >; -function parseUserAssetsByChain( - message: AddressAssetsReceivedMessage, - currency: SupportedCurrencyKey, -) { - return Object.values(message?.payload?.assets || {}).reduce( - (dict, assetData) => { - const shouldFilterToken = filterAsset(assetData?.asset); - if (!shouldFilterToken && greaterThan(assetData?.quantity, 0)) { - const parsedAsset = parseAddressAsset({ - address: assetData?.asset?.asset_code, - asset: assetData?.asset, - currency, - quantity: assetData?.quantity, - }); - dict[parsedAsset?.uniqueId] = parsedAsset; - } - return dict; - }, - {} as Record, - ); -} - // /////////////////////////////////////////////// // Query Hook export function useUserAssetsByChain( - { address, chain, currency, connectedToHardhat }: UserAssetsByChainArgs, + { address, chainId, currency, connectedToHardhat }: UserAssetsByChainArgs, config: QueryConfig< UserAssetsByChainResult, Error, @@ -197,7 +136,12 @@ export function useUserAssetsByChain( > = {}, ) { return useQuery( - userAssetsByChainQueryKey({ address, chain, currency, connectedToHardhat }), + userAssetsByChainQueryKey({ + address, + chainId, + currency, + connectedToHardhat, + }), userAssetsByChainQueryFunction, { ...config, diff --git a/src/core/resources/transactions/consolidatedTransactions.ts b/src/core/resources/transactions/consolidatedTransactions.ts new file mode 100644 index 0000000000..b762eefe7d --- /dev/null +++ b/src/core/resources/transactions/consolidatedTransactions.ts @@ -0,0 +1,165 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { addysHttp } from '~/core/network/addys'; +import { + InfiniteQueryConfig, + QueryConfig, + QueryFunctionArgs, + QueryFunctionResult, + createQueryKey, + queryClient, +} from '~/core/react-query'; +import { SupportedCurrencyKey } from '~/core/references'; +import { ChainName } from '~/core/types/chains'; +import { TransactionsReceivedMessage } from '~/core/types/refraction'; +import { RainbowTransaction } from '~/core/types/transactions'; +import { SUPPORTED_CHAIN_IDS, chainIdFromChainName } from '~/core/utils/chains'; +import { parseTransaction } from '~/core/utils/transactions'; +import { RainbowError, logger } from '~/logger'; + +const CONSOLIDATED_TRANSACTIONS_INTERVAL = 60000; +const CONSOLIDATED_TRANSACTIONS_TIMEOUT = 20000; + +// /////////////////////////////////////////////// +// Query Types + +export type ConsolidatedTransactionsArgs = { + address?: string; + currency: SupportedCurrencyKey; +}; + +// /////////////////////////////////////////////// +// Query Key + +export const consolidatedTransactionsQueryKey = ({ + address, + currency, +}: ConsolidatedTransactionsArgs) => + createQueryKey( + 'consolidatedTransactions', + { address, currency }, + { persisterVersion: 1 }, + ); + +type ConsolidatedTransactionsQueryKey = ReturnType< + typeof consolidatedTransactionsQueryKey +>; + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchConsolidatedTransactions< + TSelectData = ConsolidatedTransactionsResult, +>( + { address, currency }: ConsolidatedTransactionsArgs, + config: QueryConfig< + ConsolidatedTransactionsResult, + Error, + TSelectData, + ConsolidatedTransactionsQueryKey + >, +) { + return await queryClient.fetchQuery( + consolidatedTransactionsQueryKey({ + address, + currency, + }), + consolidatedTransactionsQueryFunction, + config, + ); +} + +// /////////////////////////////////////////////// +// Query Function + +type _QueryResult = { + cutoff?: number; + nextPage?: string; + transactions: RainbowTransaction[]; +}; + +export async function consolidatedTransactionsQueryFunction({ + queryKey: [{ address, currency }], + pageParam, +}: QueryFunctionArgs< + typeof consolidatedTransactionsQueryKey +>): Promise<_QueryResult> { + try { + const response = await addysHttp.get( + `/${SUPPORTED_CHAIN_IDS.join(',')}/${address}/transactions`, + { + params: { + currency: currency.toLowerCase(), + // passing empty value to pageParam breaks request + ...(pageParam ? { pageCursor: pageParam } : {}), + }, + timeout: CONSOLIDATED_TRANSACTIONS_TIMEOUT, + }, + ); + return { + cutoff: response?.data?.meta?.cut_off, + nextPage: response?.data?.meta?.next_page_cursor, + transactions: await parseConsolidatedTransactions( + response?.data, + currency, + ), + }; + } catch (e) { + // we don't bother with fetching cache and returning stale data here because we probably have previous page data already + logger.error(new RainbowError('consolidatedTransactionsQueryFunction: '), { + message: (e as Error)?.message, + }); + return { transactions: [] }; + } +} + +type ConsolidatedTransactionsResult = QueryFunctionResult< + typeof consolidatedTransactionsQueryFunction +>; + +async function parseConsolidatedTransactions( + message: TransactionsReceivedMessage, + currency: SupportedCurrencyKey, +) { + const data = message?.payload?.transactions || []; + const parsedTransactionPromises = data.map((tx) => + parseTransaction({ + tx, + currency, + chainId: chainIdFromChainName(tx?.network ?? ChainName.mainnet), + }), + ); + + const parsedConsolidatedTransactions = ( + await Promise.all(parsedTransactionPromises) + ).flat(); + return parsedConsolidatedTransactions; +} + +// /////////////////////////////////////////////// +// Query Hook + +export function useConsolidatedTransactions< + TSelectData = ConsolidatedTransactionsResult, +>( + { address, currency }: ConsolidatedTransactionsArgs, + config: InfiniteQueryConfig< + ConsolidatedTransactionsResult, + Error, + TSelectData + > = {}, +) { + return useInfiniteQuery( + consolidatedTransactionsQueryKey({ + address, + currency, + }), + consolidatedTransactionsQueryFunction, + { + ...config, + getNextPageParam: (lastPage) => lastPage?.nextPage, + refetchInterval: CONSOLIDATED_TRANSACTIONS_INTERVAL, + retry: 3, + }, + ); +} diff --git a/src/core/resources/transactions/registryLookup.ts b/src/core/resources/transactions/registryLookup.ts index 31e2855e35..86bf166780 100644 --- a/src/core/resources/transactions/registryLookup.ts +++ b/src/core/resources/transactions/registryLookup.ts @@ -52,7 +52,7 @@ async function registryLookupQueryFunction({ if ((!data || data === '0x') && hash) { const provider = getProvider({ chainId }); const tx = await provider.getTransaction(hash); - dataToLookup = tx.data; + dataToLookup = tx?.data; } if (!dataToLookup || dataToLookup === '0x' || dataToLookup.length < 10) { diff --git a/src/core/resources/transactions/transactions.ts b/src/core/resources/transactions/transactions.ts index 25b2df39c3..4831a63980 100644 --- a/src/core/resources/transactions/transactions.ts +++ b/src/core/resources/transactions/transactions.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { refractionAddressWs } from '~/core/network'; +import { addysHttp } from '~/core/network/addys'; import { QueryConfig, QueryFunctionArgs, @@ -12,13 +12,10 @@ import { SupportedCurrencyKey } from '~/core/references'; import { ChainId, ChainName } from '~/core/types/chains'; import { TransactionsReceivedMessage } from '~/core/types/refraction'; import { RainbowTransaction } from '~/core/types/transactions'; -import { - chainIdFromChainName, - chainNameFromChainId, -} from '~/core/utils/chains'; +import { chainIdFromChainName } from '~/core/utils/chains'; import { parseTransaction } from '~/core/utils/transactions'; +import { RainbowError, logger } from '~/logger'; -const TRANSACTIONS_TIMEOUT_DURATION = 35000; const TRANSACTIONS_REFETCH_INTERVAL = 60000; // /////////////////////////////////////////////// @@ -75,39 +72,28 @@ async function transactionsQueryFunction({ }: QueryFunctionArgs): Promise< RainbowTransaction[] > { - const isMainnet = chainId === ChainId.mainnet; - const scope = [ - `${isMainnet ? '' : chainNameFromChainId(chainId) + '-'}transactions`, - ]; - const event = `received address ${scope[0]}`; - refractionAddressWs.emit('get', { - payload: { - address, - currency: currency.toLowerCase(), - transactions_limit: transactionsLimit ?? 250, - }, - scope, - }); - return new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve( - queryClient.getQueryData( - transactionsQueryKey({ - address, - chainId, - currency, - transactionsLimit, - }), - ) || [], - ); - }, TRANSACTIONS_TIMEOUT_DURATION); - const resolver = async (message: TransactionsReceivedMessage) => { - clearTimeout(timeout); - const transactions = await parseTransactions(message, currency); - resolve(transactions); - }; - refractionAddressWs.once(event, resolver); - }); + try { + const response = await addysHttp.get( + `/${chainId}/${address}/transactions`, + { + params: { + currency: currency.toLowerCase(), + limit: transactionsLimit?.toString() || '100', + }, + timeout: 30000, + }, + ); + return parseTransactions(response?.data, currency); + } catch (e) { + const cache = queryClient.getQueryCache(); + const cachedTransactions = cache.find( + transactionsQueryKey({ address, chainId, currency, transactionsLimit }), + )?.state?.data as RainbowTransaction[]; + logger.error(new RainbowError('transactionsQueryFunction: '), { + message: (e as Error)?.message, + }); + return cachedTransactions; + } } type TransactionsResult = QueryFunctionResult; diff --git a/src/core/types/refraction.ts b/src/core/types/refraction.ts index a8d118414b..180bed0577 100644 --- a/src/core/types/refraction.ts +++ b/src/core/types/refraction.ts @@ -1,5 +1,5 @@ import { ZerionAsset } from '~/core/types/assets'; -import { ChainName } from '~/core/types/chains'; +import { ChainId, ChainName } from '~/core/types/chains'; import { ZerionTransaction } from '~/core/types/transactions'; /** @@ -8,9 +8,13 @@ import { ZerionTransaction } from '~/core/types/transactions'; 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; } /** @@ -18,12 +22,7 @@ export interface MessageMeta { */ export interface AddressAssetsReceivedMessage { payload?: { - assets?: { - [id: string]: { - asset: ZerionAsset; - quantity: string; - }; - }; + assets?: { asset: ZerionAsset; quantity: string }[]; }; meta?: MessageMeta; } diff --git a/src/core/types/transactions.ts b/src/core/types/transactions.ts index 9414daed84..b1fed91110 100644 --- a/src/core/types/transactions.ts +++ b/src/core/types/transactions.ts @@ -3,7 +3,7 @@ import { TransactionResponse } from '@ethersproject/providers'; import { Address } from 'wagmi'; import { ParsedAsset, ZerionAsset } from './assets'; -import { ChainId } from './chains'; +import { ChainId, ChainName } from './chains'; export interface RainbowTransaction { address?: Address; @@ -53,6 +53,7 @@ export interface ZerionTransaction { id: string; meta: ZerionTransactionMeta; mined_at: number; + network?: ChainName; nonce: number; protocol: ProtocolType; status: ZerionTransactionStatus; diff --git a/src/core/utils/chains.ts b/src/core/utils/chains.ts index ee6771f446..2ab419a875 100644 --- a/src/core/utils/chains.ts +++ b/src/core/utils/chains.ts @@ -14,6 +14,8 @@ export const SUPPORTED_CHAINS: Chain[] = [ bsc, ].map((chain) => ({ ...chain, name: ChainNameDisplay[chain.id] })); +export const SUPPORTED_CHAIN_IDS = SUPPORTED_CHAINS.map(({ id }) => id); + /** * @desc Checks if the given chain is a Layer 2. * @param chain The chain name to check. diff --git a/src/core/utils/txWatcher.ts b/src/core/utils/txWatcher.ts deleted file mode 100644 index 7e0dfb6dc7..0000000000 --- a/src/core/utils/txWatcher.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { getProvider } from '@wagmi/core'; -import { Address } from 'wagmi'; - -import { fetchTransactions } from '../resources/transactions/transactions'; -import { currentCurrencyStore, pendingTransactionsStore } from '../state'; -import { TransactionStatus } from '../types/transactions'; - -import { - getPendingTransactionData, - getTransactionFlashbotStatus, - getTransactionHash, - getTransactionReceiptStatus, -} from './transactions'; - -export async function watchPendingTransactions({ - address, -}: { - address: Address; -}) { - const { getPendingTransactions, setPendingTransactions } = - pendingTransactionsStore.getState(); - const pendingTransactions = getPendingTransactions({ - address, - }); - const { currentCurrency } = currentCurrencyStore.getState(); - - if (!pendingTransactions?.length) return; - - const updatedPendingTransactions = await Promise.all( - pendingTransactions.map(async (tx) => { - let updatedTransaction = { ...tx }; - const txHash = getTransactionHash(tx); - try { - const chainId = tx?.chainId; - if (chainId) { - const provider = getProvider({ chainId }); - if (txHash) { - const currentNonceForChainId = await provider.getTransactionCount( - address, - 'latest', - ); - const transactionResponse = await provider.getTransaction(txHash); - const nonceAlreadyIncluded = - currentNonceForChainId > - (tx?.nonce || transactionResponse?.nonce); - const transactionStatus = await getTransactionReceiptStatus({ - included: nonceAlreadyIncluded, - transaction: tx, - transactionResponse, - }); - let pendingTransactionData = getPendingTransactionData({ - transaction: tx, - transactionStatus, - }); - - if ( - (transactionResponse?.blockNumber && - transactionResponse?.blockHash) || - nonceAlreadyIncluded - ) { - const latestTransactionsConfirmedByBackend = - await fetchTransactions( - { - address, - chainId, - currency: currentCurrency, - transactionsLimit: 1, - }, - { cacheTime: 0 }, - ); - const latest = latestTransactionsConfirmedByBackend?.[0]; - if (latest && getTransactionHash(latest) === tx?.hash) { - updatedTransaction = { - ...updatedTransaction, - ...latest, - }; - } else { - updatedTransaction = { - ...updatedTransaction, - ...pendingTransactionData, - }; - } - } else if (tx.flashbots) { - const flashbotsTxStatus = await getTransactionFlashbotStatus( - updatedTransaction, - txHash, - ); - if (flashbotsTxStatus) { - pendingTransactionData = flashbotsTxStatus; - } - } - } - } else { - throw new Error('Pending transaction missing chain id'); - } - } catch (e) { - console.log('ERROR WATCHING PENDING TX: ', e); - } - return updatedTransaction; - }), - ); - - setPendingTransactions({ - address, - pendingTransactions: updatedPendingTransactions.filter( - (tx) => tx?.status !== TransactionStatus?.unknown, - ), - }); -} diff --git a/src/entries/popup/hooks/useAllTransactions.ts b/src/entries/popup/hooks/useAllTransactions.ts deleted file mode 100644 index 1a383cd52a..0000000000 --- a/src/entries/popup/hooks/useAllTransactions.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Address, useNetwork } from 'wagmi'; - -import { SupportedCurrencyKey } from '~/core/references'; -import { shortcuts } from '~/core/references/shortcuts'; -import { useTransactions } from '~/core/resources/transactions/transactions'; -import { - currentAddressStore, - nonceStore, - pendingTransactionsStore, - usePendingTransactionsStore, -} from '~/core/state'; -import { ChainId } from '~/core/types/chains'; -import { RainbowTransaction } from '~/core/types/transactions'; -import { isLowerCaseMatch } from '~/core/utils/strings'; - -import { useKeyboardShortcut } from './useKeyboardShortcut'; - -export function useAllTransactions({ - address, - currency, -}: { - address?: Address; - currency: SupportedCurrencyKey; -}) { - const [manuallyRefetching, setManuallyRefetching] = useState(false); - const currentChain = useNetwork(); - const { - data: confirmedTransactions, - isInitialLoading: confirmedInitialLoading, - refetch: refetchConfirmed, - } = useTransactions( - { - address, - chainId: ChainId.mainnet, - currency, - }, - { - onSuccess: (transactions: RainbowTransaction[]) => - watchConfirmedTransactions( - transactions, - currentChain?.chain?.id || ChainId.mainnet, - ), - }, - ); - const { - data: confirmedArbitrumTransactions, - isInitialLoading: arbitrumInitialLoading, - refetch: refetchArbitrum, - } = useTransactions( - { - address, - chainId: ChainId.arbitrum, - currency, - }, - { - onSuccess: (transactions: RainbowTransaction[]) => - watchConfirmedTransactions(transactions, ChainId.arbitrum), - }, - ); - const { - data: confirmedBscTransactions, - isInitialLoading: bscInitialLoading, - refetch: refetchBsc, - } = useTransactions( - { - address, - chainId: ChainId.bsc, - currency, - }, - { - onSuccess: (transactions: RainbowTransaction[]) => - watchConfirmedTransactions(transactions, ChainId.bsc), - }, - ); - const { - data: confirmedOptimismTransactions, - isInitialLoading: optimismInitialLoading, - refetch: refetchOptimism, - } = useTransactions( - { - address, - chainId: ChainId.optimism, - currency, - }, - { - onSuccess: (transactions: RainbowTransaction[]) => - watchConfirmedTransactions(transactions, ChainId.optimism), - }, - ); - const { - data: confirmedPolygonTransactions, - isInitialLoading: polygonInitialLoading, - refetch: refetchPolygon, - } = useTransactions( - { - address, - chainId: ChainId.polygon, - currency, - }, - { - onSuccess: (transactions: RainbowTransaction[]) => - watchConfirmedTransactions(transactions, ChainId.polygon), - }, - ); - - const refetchTransactions = async () => { - setManuallyRefetching(true); - const queries = [ - refetchArbitrum(), - refetchBsc(), - refetchConfirmed(), - refetchOptimism(), - refetchPolygon(), - ]; - await Promise.all(queries); - setManuallyRefetching(false); - }; - - useKeyboardShortcut({ - handler: (e: KeyboardEvent) => { - if (e.key === shortcuts.activity.REFRESH_TRANSACTIONS.key) { - refetchTransactions(); - } - }, - condition: () => !manuallyRefetching, - }); - - const { getPendingTransactions } = usePendingTransactionsStore(); - const pendingTransactions: RainbowTransaction[] = getPendingTransactions({ - address, - }); - - const isInitialLoading = - confirmedInitialLoading || - arbitrumInitialLoading || - bscInitialLoading || - optimismInitialLoading || - polygonInitialLoading || - manuallyRefetching; - - return useMemo( - () => ({ - allTransactions: [ - ...pendingTransactions, - ...(confirmedTransactions || []), - ...(confirmedArbitrumTransactions || []), - ...(confirmedBscTransactions || []), - ...(confirmedOptimismTransactions || []), - ...(confirmedPolygonTransactions || []), - ], - isInitialLoading, - }), - [ - confirmedArbitrumTransactions, - confirmedBscTransactions, - confirmedOptimismTransactions, - confirmedPolygonTransactions, - confirmedTransactions, - pendingTransactions, - isInitialLoading, - ], - ); -} - -function watchConfirmedTransactions( - transactions: RainbowTransaction[], - currentChainId: ChainId, -) { - const { currentAddress } = currentAddressStore.getState(); - const { getPendingTransactions, setPendingTransactions } = - pendingTransactionsStore.getState(); - const { setNonce } = nonceStore.getState(); - const pendingTransactions = getPendingTransactions({ - address: currentAddress, - }); - - const txSortedByDescendingNonce = transactions - .filter((tx) => { - return isLowerCaseMatch(tx?.from, currentAddress); - }) - .sort(({ nonce: n1 }, { nonce: n2 }) => (n2 ?? 0) - (n1 ?? 0)); - const latestTx = txSortedByDescendingNonce?.[0]; - const latestConfirmedNonce = latestTx?.nonce || 0; - let currentNonce: number; - const latestPendingTx = pendingTransactions?.filter( - (tx) => tx?.chainId === currentChainId, - )?.[0]; - - if (latestPendingTx) { - const latestPendingNonce = latestPendingTx?.nonce || 0; - currentNonce = - latestPendingNonce > latestConfirmedNonce - ? latestPendingNonce - : latestConfirmedNonce; - } else { - currentNonce = latestConfirmedNonce; - } - - setNonce({ - address: currentAddress, - chainId: currentChainId, - currentNonce, - latestConfirmedNonce, - }); - - const updatedPendingTx = pendingTransactions?.filter((tx) => { - if (tx?.chainId !== currentChainId) { - return true; - } - // remove pending tx because backend is now returning full tx data - if ((tx?.nonce || 0) <= latestConfirmedNonce) { - return false; - } - // either still pending or backend is not returning confirmation yet - return true; - }); - - setPendingTransactions({ - address: currentAddress, - pendingTransactions: updatedPendingTx, - }); -} diff --git a/src/entries/popup/hooks/useInfiniteTransactionList.ts b/src/entries/popup/hooks/useInfiniteTransactionList.ts new file mode 100644 index 0000000000..0151e2d7bd --- /dev/null +++ b/src/entries/popup/hooks/useInfiniteTransactionList.ts @@ -0,0 +1,231 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useEffect, useMemo, useState } from 'react'; +import { Address } from 'wagmi'; + +import { queryClient } from '~/core/react-query'; +import { shortcuts } from '~/core/references/shortcuts'; +import { selectTransactionsByDate } from '~/core/resources/_selectors'; +import { + consolidatedTransactionsQueryKey, + useConsolidatedTransactions, +} from '~/core/resources/transactions/consolidatedTransactions'; +import { + nonceStore, + pendingTransactionsStore, + useCurrentAddressStore, + useCurrentCurrencyStore, + usePendingTransactionsStore, +} from '~/core/state'; +import { ChainId } from '~/core/types/chains'; +import { RainbowTransaction } from '~/core/types/transactions'; +import { SUPPORTED_CHAIN_IDS } from '~/core/utils/chains'; + +import { useKeyboardShortcut } from './useKeyboardShortcut'; + +const PAGES_TO_CACHE_LIMIT = 2; + +interface UseInfiniteTransactionListParams { + getScrollElement: () => HTMLDivElement | null; +} + +export default function ({ + getScrollElement, +}: UseInfiniteTransactionListParams) { + const { currentAddress: address } = useCurrentAddressStore(); + const { currentCurrency: currency } = useCurrentCurrencyStore(); + const { getPendingTransactions } = usePendingTransactionsStore(); + const [manuallyRefetching, setManuallyRefetching] = useState(false); + const pendingTransactions = getPendingTransactions({ address }); + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isInitialLoading, + refetch, + status, + } = useConsolidatedTransactions( + { address, currency }, + { + onSuccess: (data) => { + if (data?.pages) { + const latestTransactions = data.pages + .map((p) => p.transactions) + .flat() + .reduce((latestTxMap, currentTx) => { + const currentChain = currentTx?.chainId; + if (currentChain) { + const latestTx = latestTxMap.get(currentChain); + if (!latestTx) { + latestTxMap.set(currentChain, currentTx); + } + } + return latestTxMap; + }, new Map(SUPPORTED_CHAIN_IDS.map((chain) => [chain, null as RainbowTransaction | null]))); + watchForPendingTransactionsReportedByRainbowBackend({ + currentAddress: address, + pendingTransactions, + latestTransactions, + }); + } + }, + }, + ); + const pages = data?.pages; + const cutoff = + data?.pages && data?.pages?.length + ? data?.pages[data?.pages.length - 1]?.cutoff + : null; + const transactions = useMemo( + () => pages?.flatMap((p) => p.transactions) || [], + [pages], + ); + const transactionsAfterCutoff = useMemo(() => { + if (!cutoff) return transactions; + const cutoffIndex = transactions.findIndex( + (tx) => (tx.minedAt || Infinity) < cutoff, + ); + if (!cutoffIndex) return transactions; + return [...transactions].slice(0, cutoffIndex); + }, [cutoff, transactions]); + const formattedTransactions = useMemo( + () => + Object.entries( + selectTransactionsByDate( + pendingTransactions.concat(transactionsAfterCutoff), + ), + ).flat(2), + [pendingTransactions, transactionsAfterCutoff], + ); + + const infiniteRowVirtualizer = useVirtualizer({ + count: formattedTransactions?.length, + getScrollElement, + estimateSize: (i) => + typeof formattedTransactions[i] === 'string' ? 34 : 52, + overscan: 20, + }); + const rows = infiniteRowVirtualizer.getVirtualItems(); + + useEffect(() => { + return () => { + if (data && data?.pages) { + queryClient.setQueryData( + consolidatedTransactionsQueryKey({ address, currency }), + { + ...data, + pages: [...data.pages].slice(0, PAGES_TO_CACHE_LIMIT), + }, + ); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const [lastRow] = [...rows].reverse(); + if (!lastRow) return; + if ( + lastRow.index >= transactions.length - 1 && + hasNextPage && + !isFetching && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }, [ + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + transactions.length, + rows, + ]); + + const refetchTransactions = async () => { + setManuallyRefetching(true); + await refetch(); + setManuallyRefetching(false); + }; + + useKeyboardShortcut({ + handler: (e: KeyboardEvent) => { + if (e.key === shortcuts.activity.REFRESH_TRANSACTIONS.key) { + refetchTransactions(); + } + }, + condition: () => !manuallyRefetching, + }); + + return { + error, + fetchNextPage, + isFetching, + isFetchingNextPage, + isInitialLoading, + status, + transactions: formattedTransactions, + virtualizer: infiniteRowVirtualizer, + isRefetching: manuallyRefetching, + }; +} + +function watchForPendingTransactionsReportedByRainbowBackend({ + currentAddress, + pendingTransactions, + latestTransactions, +}: { + currentAddress: Address; + pendingTransactions: RainbowTransaction[]; + latestTransactions: Map; +}) { + const { setNonce } = nonceStore.getState(); + const { setPendingTransactions } = pendingTransactionsStore.getState(); + + for (const supportedChainId of SUPPORTED_CHAIN_IDS) { + const latestTxConfirmedByBackend = latestTransactions.get(supportedChainId); + if (!latestTxConfirmedByBackend) return; + const latestNonceConfirmedByBackend = latestTxConfirmedByBackend.nonce || 0; + const [latestPendingTx] = pendingTransactions.filter( + (tx) => tx?.chainId === supportedChainId, + ); + + let currentNonce; + if (latestPendingTx) { + const latestPendingNonce = latestPendingTx?.nonce || 0; + const latestTransactionIsPending = + latestPendingNonce > latestNonceConfirmedByBackend; + currentNonce = latestTransactionIsPending + ? latestPendingNonce + : latestNonceConfirmedByBackend; + } else { + currentNonce = latestNonceConfirmedByBackend; + } + + setNonce({ + address: currentAddress, + chainId: supportedChainId, + currentNonce, + latestConfirmedNonce: latestNonceConfirmedByBackend, + }); + } + + const updatedPendingTx = pendingTransactions?.filter((tx) => { + const { chainId, nonce } = tx; + const latestConfirmedNonce = latestTransactions.get(chainId)?.nonce || 0; + // remove pending tx because backend is now returning full tx data + if ((nonce || 0) <= latestConfirmedNonce) { + return false; + } + // either still pending or backend is not returning confirmation yet + return true; + }); + + setPendingTransactions({ + address: currentAddress, + pendingTransactions: updatedPendingTx, + }); +} diff --git a/src/entries/popup/hooks/useWatchPendingTransactions.ts b/src/entries/popup/hooks/useWatchPendingTransactions.ts index 428e1ee2b6..677f28a6e0 100644 --- a/src/entries/popup/hooks/useWatchPendingTransactions.ts +++ b/src/entries/popup/hooks/useWatchPendingTransactions.ts @@ -2,14 +2,17 @@ import { getProvider } from '@wagmi/core'; import { useCallback } from 'react'; import { Address } from 'wagmi'; +import { queryClient } from '~/core/react-query'; import { userAssetsFetchQuery } from '~/core/resources/assets/userAssets'; import { fetchTransactions } from '~/core/resources/transactions/transactions'; import { + nonceStore, useCurrentCurrencyStore, usePendingTransactionsStore, } from '~/core/state'; import { useConnectedToHardhatStore } from '~/core/state/currentSettings/connectedToHardhat'; import { TransactionStatus, TransactionType } from '~/core/types/transactions'; +import { isLowerCaseMatch } from '~/core/utils/strings'; import { getPendingTransactionData, getTransactionFlashbotStatus, @@ -42,14 +45,19 @@ export const useWatchPendingTransactions = ({ const { swapRefreshAssets } = useSwapRefreshAssets(); const { getPendingTransactions, setPendingTransactions } = usePendingTransactionsStore(); + const { setNonce } = nonceStore.getState(); const { currentCurrency } = useCurrentCurrencyStore(); const { connectedToHardhat } = useConnectedToHardhatStore(); + const pendingTransactions = getPendingTransactions({ + address, + }); + const pendingTransactionsByDescendingNonce = pendingTransactions + .filter((tx) => isLowerCaseMatch(tx?.from, address)) + .sort(({ nonce: n1 }, { nonce: n2 }) => (n2 ?? 0) - (n1 ?? 0)); const watchPendingTransactions = useCallback(async () => { - const pendingTransactions = getPendingTransactions({ - address, - }); if (!pendingTransactions?.length || !address) return; + let pendingTransactionReportedByRainbowBackend = false; const updatedPendingTransactions = await Promise.all( pendingTransactions.map(async (tx) => { let updatedTransaction = { ...tx }; @@ -59,13 +67,11 @@ export const useWatchPendingTransactions = ({ if (chainId) { const provider = getProvider({ chainId }); if (txHash) { - const currentNonceForChainId = await provider.getTransactionCount( - address, - 'latest', - ); + const currentTxCountForChainId = + await provider.getTransactionCount(address, 'latest'); const transactionResponse = await provider.getTransaction(txHash); const nonceAlreadyIncluded = - currentNonceForChainId > + currentTxCountForChainId > (tx?.nonce || transactionResponse?.nonce); const transactionStatus = await getTransactionReceiptStatus({ included: nonceAlreadyIncluded, @@ -103,7 +109,32 @@ export const useWatchPendingTransactions = ({ { cacheTime: 0 }, ); const latest = latestTransactionsConfirmedByBackend?.[0]; - if (latest && getTransactionHash(latest) === tx?.hash) { + + const latestPendingNonceForChainId = + pendingTransactionsByDescendingNonce?.filter( + (tx) => tx?.chainId === chainId, + )?.[0]?.nonce || 0; + const currentNonceForChainId = + currentTxCountForChainId - 1 || 0; + const latestTransactionHashConfirmedByBackend = latest + ? getTransactionHash(latest) + : null; + + setNonce({ + address, + chainId, + currentNonce: + currentNonceForChainId > latestPendingNonceForChainId + ? currentNonceForChainId + : latestPendingNonceForChainId, + latestConfirmedNonce: latest?.nonce, + }); + + if (tx?.nonce && latest?.nonce && tx?.nonce <= latest?.nonce) { + pendingTransactionReportedByRainbowBackend = true; + } + + if (latestTransactionHashConfirmedByBackend === tx?.hash) { updatedTransaction = { ...updatedTransaction, ...latest, @@ -134,6 +165,13 @@ export const useWatchPendingTransactions = ({ }), ); + if (pendingTransactionReportedByRainbowBackend) { + queryClient.refetchQueries({ + predicate: (query) => + query.queryKey.includes('consolidatedTransactions'), + }); + } + setPendingTransactions({ address, pendingTransactions: updatedPendingTransactions.filter((tx) => @@ -144,7 +182,9 @@ export const useWatchPendingTransactions = ({ address, connectedToHardhat, currentCurrency, - getPendingTransactions, + pendingTransactions, + pendingTransactionsByDescendingNonce, + setNonce, setPendingTransactions, swapRefreshAssets, ]); diff --git a/src/entries/popup/pages/home/Activity.tsx b/src/entries/popup/pages/home/Activity.tsx index 4b37383568..3d382116de 100644 --- a/src/entries/popup/pages/home/Activity.tsx +++ b/src/entries/popup/pages/home/Activity.tsx @@ -1,11 +1,7 @@ -import { useVirtualizer } from '@tanstack/react-virtual'; import { motion } from 'framer-motion'; import React, { ReactNode, useMemo } from 'react'; -import { useAccount } from 'wagmi'; import { i18n } from '~/core/languages'; -import { selectTransactionsByDate } from '~/core/resources/_selectors'; -import { useCurrentCurrencyStore } from '~/core/state'; import { RainbowTransaction, TransactionStatus, @@ -30,38 +26,29 @@ import { CoinRow } from '~/entries/popup/components/CoinRow/CoinRow'; import { ActivitySkeleton } from '../../components/ActivitySkeleton/ActivitySkeleton'; import { Spinner } from '../../components/Spinner/Spinner'; import { useActivityShortcuts } from '../../hooks/useActivityShortcuts'; -import { useAllTransactions } from '../../hooks/useAllTransactions'; +import useInfiniteTransactionList from '../../hooks/useInfiniteTransactionList'; import { TransactionDetailsMenu } from './TransactionDetailsMenu'; export function Activity() { - const { address } = useAccount(); - const { currentCurrency: currency } = useCurrentCurrencyStore(); - const { allTransactions, isInitialLoading } = useAllTransactions({ - address, - currency, - }); - - const listData = useMemo( - () => Object.entries(selectTransactionsByDate(allTransactions)).flat(2), - [allTransactions], - ); - - const containerRef = useContainerRef(); - const activityRowVirtualizer = useVirtualizer({ - count: listData.length, + const { + isInitialLoading, + isFetchingNextPage, + isRefetching, + transactions, + virtualizer: activityRowVirtualizer, + } = useInfiniteTransactionList({ getScrollElement: () => containerRef.current, - estimateSize: (i) => (typeof listData[i] === 'string' ? 34 : 52), - overscan: 20, }); + const containerRef = useContainerRef(); useActivityShortcuts(); - if (isInitialLoading) { + if (isInitialLoading || isRefetching) { return ; } - if (!listData.length) { + if (!transactions.length) { return ( 6 ? 8 : 60, + paddingBottom: transactions.length > 6 ? 8 : 60, }} > - {activityRowVirtualizer.getVirtualItems().map((virtualItem) => { + {rows.map((virtualItem) => { const { index, key, start, size } = virtualItem; - const rowData = listData[index]; + const rowData = transactions[index]; const isLabel = typeof rowData === 'string'; - if (isLabel) labelsCount += 1; return ( + {isFetchingNextPage && ( + + + + )} ); } @@ -218,7 +216,6 @@ const titleIcons: { }, }; -// TODO: create truncation component const truncateString = (txt = '', maxLength = 22) => { return `${txt?.slice(0, maxLength)}${txt.length > maxLength ? '...' : ''}`; };