From 2fa940d32a8168afc2be5d3b75dbf86ee7e61aab Mon Sep 17 00:00:00 2001 From: Bartek Date: Mon, 5 May 2025 13:50:19 +0200 Subject: [PATCH 1/2] indexer init --- .../src/hooks/useTransactionHistory.ts | 730 +++--------------- 1 file changed, 90 insertions(+), 640 deletions(-) diff --git a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts index 74fef2b159..0a2702d495 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts @@ -1,22 +1,13 @@ -import { useAccount, useNetwork } from 'wagmi' -import useSWRImmutable from 'swr/immutable' -import useSWRInfinite from 'swr/infinite' -import { useCallback, useEffect, useMemo, useState } from 'react' -import dayjs from 'dayjs' - -import { getChains, getChildChainIds, isNetwork } from '../util/networks' +import { getChains, getChildChainIds } from '../util/networks' import { ChainId } from '../types/ChainId' -import { fetchWithdrawals } from '../util/withdrawals/fetchWithdrawals' -import { fetchDeposits } from '../util/deposits/fetchDeposits' import { AssetType, L2ToL1EventResultPlus, WithdrawalInitiated } from './arbTokenBridge.types' -import { isTeleportTx, Transaction } from '../types/Transactions' +import { Transaction } from '../types/Transactions' import { MergedTransaction } from '../state/app/state' import { - isCustomDestinationAddressTx, normalizeTimestamp, transformDeposit, transformWithdrawal @@ -30,45 +21,24 @@ import { } from '../util/withdrawals/helpers' import { WithdrawalFromSubgraph } from '../util/withdrawals/fetchWithdrawalsFromSubgraph' import { updateAdditionalDepositData } from '../util/deposits/helpers' -import { useCctpFetching } from '../state/cctpState' import { - getDepositsWithoutStatusesFromCache, - getUpdatedCctpTransfer, - getUpdatedEthDeposit, - getUpdatedTeleportTransfer, - getUpdatedRetryableDeposit, - getUpdatedWithdrawal, isCctpTransfer, - isSameTransaction, - isTxPending, isOftTransfer } from '../components/TransactionHistory/helpers' -import { useIsTestnetMode } from './useIsTestnetMode' -import { useAccountType } from './useAccountType' -import { - shouldIncludeReceivedTxs, - shouldIncludeSentTxs -} from '../util/SubgraphUtils' -import { isValidTeleportChainPair } from '@/token-bridge-sdk/teleport' import { getProviderForChainId } from '@/token-bridge-sdk/utils' import { Address } from '../util/AddressUtils' -import { - TeleportFromSubgraph, - fetchTeleports -} from '../util/teleports/fetchTeleports' +import { TeleportFromSubgraph } from '../util/teleports/fetchTeleports' import { isTransferTeleportFromSubgraph, transformTeleportFromSubgraph } from '../util/teleports/helpers' -import { captureSentryErrorWithExtraData } from '../util/SentryUtils' -import { useArbQueryParams } from './useArbQueryParams' import { - getUpdatedOftTransfer, LayerZeroTransaction, - updateAdditionalLayerZeroData, - useOftTransactionHistory + updateAdditionalLayerZeroData } from './useOftTransactionHistory' import { create } from 'zustand' +import useSWR from 'swr' +import { BigNumber } from 'ethers' export type UseTransactionHistoryResult = { transactions: MergedTransaction[] @@ -257,620 +227,100 @@ function dedupeTransactions(txs: Transfer[]) { ) } -/** - * Fetches transaction history only for deposits and withdrawals, without their statuses. - */ -const useTransactionHistoryWithoutStatuses = (address: Address | undefined) => { - const { chain } = useNetwork() - const [isTestnetMode] = useIsTestnetMode() - const { isSmartContractWallet, isLoading: isLoadingAccountType } = - useAccountType() - const [{ txHistory: isTxHistoryEnabled }] = useArbQueryParams() - const forceFetchReceived = useForceFetchReceived( - state => state.forceFetchReceived - ) - - const cctpTransfersMainnet = useCctpFetching({ - walletAddress: address, - l1ChainId: ChainId.Ethereum, - l2ChainId: ChainId.ArbitrumOne, - pageNumber: 0, - pageSize: 1000, - type: 'all' - }) - - const cctpTransfersTestnet = useCctpFetching({ - walletAddress: address, - l1ChainId: ChainId.Sepolia, - l2ChainId: ChainId.ArbitrumSepolia, - pageNumber: 0, - pageSize: 1000, - type: 'all' - }) - - const combinedCctpMainnetTransfers = [ - ...(cctpTransfersMainnet.deposits?.completed || []), - ...(cctpTransfersMainnet.withdrawals?.completed || []), - ...(cctpTransfersMainnet.deposits?.pending || []), - ...(cctpTransfersMainnet.withdrawals?.pending || []) - ] - - const combinedCctpTestnetTransfers = [ - ...(cctpTransfersTestnet.deposits?.completed || []), - ...(cctpTransfersTestnet.withdrawals?.completed || []), - ...(cctpTransfersTestnet.deposits?.pending || []), - ...(cctpTransfersTestnet.withdrawals?.pending || []) - ] - - const cctpLoading = - cctpTransfersMainnet.isLoadingDeposits || - cctpTransfersMainnet.isLoadingWithdrawals || - cctpTransfersTestnet.isLoadingDeposits || - cctpTransfersTestnet.isLoadingWithdrawals - - const { transactions: oftTransfers, isLoading: oftLoading } = - useOftTransactionHistory({ - walletAddress: address, - isTestnet: isTestnetMode - }) - - const { data: failedChainPairs, mutate: addFailedChainPair } = - useSWRImmutable( - address ? ['failed_chain_pairs', address] : null +function indexerTransactionToMergedTransaction( + tx: IndexerTransaction +): MergedTransaction { + return { + sender: tx.sourceChain.address, + destination: tx.destinationChain.address, + direction: [TransferType.ETH_DEPOSIT, TransferType.ERC20_DEPOSIT].includes( + tx.transferType ) + ? 'deposit-l1' + : 'withdraw', + status: 'pending', + createdAt: tx.sourceChain.createdAt, + resolvedAt: tx.destinationChain.settledAt, + txId: tx.sourceChain.transactionHash, + asset: tx.sourceChain.token ? tx.sourceChain.token.symbol : 'ETH', + assetType: tx.sourceChain.token ? AssetType.ERC20 : AssetType.ETH, + value: tx.sourceChain.amount.toString(), + uniqueId: BigNumber.from(0), + isWithdrawal: [ + TransferType.ETH_WITHDRAWAL, + TransferType.ERC20_WITHDRAWAL + ].includes(tx.transferType), + blockNum: 0, + tokenAddress: tx.sourceChain.token?.address ?? null, + isCctp: false, + isOft: false, + nodeBlockDeadline: 0, + depositStatus: 1, + parentChainId: tx.parentChain.chainId, + childChainId: tx.childChain.chainId, + sourceChainId: tx.sourceChain.chainId, + destinationChainId: tx.destinationChain.chainId + } +} - const fetcher = useCallback( - (type: 'deposits' | 'withdrawals') => { - if (!chain) { - return [] - } - - const fetcherFn = type === 'deposits' ? fetchDeposits : fetchWithdrawals - - return Promise.all( - getMultiChainFetchList() - .filter(chainPair => { - if (isSmartContractWallet) { - // only fetch txs from the connected network - return [chainPair.parentChainId, chainPair.childChainId].includes( - chain.id - ) - } - - return ( - isNetwork(chainPair.parentChainId).isTestnet === isTestnetMode - ) - }) - .map(async chainPair => { - // SCW address is tied to a specific network - // that's why we need to limit shown txs either to sent or received funds - // otherwise we'd display funds for a different network, which could be someone else's account - const isConnectedToParentChain = - chainPair.parentChainId === chain.id - - const includeSentTxs = shouldIncludeSentTxs({ - type, - isSmartContractWallet, - isConnectedToParentChain - }) - - const includeReceivedTxs = shouldIncludeReceivedTxs({ - type, - isSmartContractWallet, - isConnectedToParentChain - }) - try { - // early check for fetching teleport - if ( - isValidTeleportChainPair({ - sourceChainId: chainPair.parentChainId, - destinationChainId: chainPair.childChainId - }) - ) { - // teleporter does not support withdrawals - if (type === 'withdrawals') return [] - - return await fetchTeleports({ - sender: includeSentTxs ? address : undefined, - receiver: includeReceivedTxs ? address : undefined, - parentChainProvider: getProviderForChainId( - chainPair.parentChainId - ), - childChainProvider: getProviderForChainId( - chainPair.childChainId - ), - pageNumber: 0, - pageSize: 1000 - }) - } - - // else, fetch deposits or withdrawals - return await fetcherFn({ - sender: includeSentTxs ? address : undefined, - receiver: includeReceivedTxs ? address : undefined, - l1Provider: getProviderForChainId(chainPair.parentChainId), - l2Provider: getProviderForChainId(chainPair.childChainId), - pageNumber: 0, - pageSize: 1000, - forceFetchReceived - }) - } catch { - addFailedChainPair(prevFailedChainPairs => { - if (!prevFailedChainPairs) { - return [chainPair] - } - if ( - typeof prevFailedChainPairs.find( - prevPair => - prevPair.parentChainId === chainPair.parentChainId && - prevPair.childChainId === chainPair.childChainId - ) !== 'undefined' - ) { - // already added - return prevFailedChainPairs - } - - return [...prevFailedChainPairs, chainPair] - }) - - return [] - } - }) - ) - }, - [ - address, - isTestnetMode, - addFailedChainPair, - isSmartContractWallet, - chain, - forceFetchReceived - ] - ) - - const shouldFetch = - address && chain && !isLoadingAccountType && isTxHistoryEnabled - - const { - data: depositsData, - error: depositsError, - isLoading: depositsLoading - } = useSWRImmutable( - shouldFetch ? ['tx_list', 'deposits', address, isTestnetMode] : null, - () => fetcher('deposits') - ) - - const { - data: withdrawalsData, - error: withdrawalsError, - isLoading: withdrawalsLoading - } = useSWRImmutable( - shouldFetch - ? ['tx_list', 'withdrawals', address, isTestnetMode, forceFetchReceived] - : null, - () => fetcher('withdrawals') - ) - - const deposits = (depositsData || []).flat() +interface TokenDetails { + symbol: string + decimals: number + address: string + name: string +} - const withdrawals = (withdrawalsData || []).flat() +type IndexerPartialTransaction = { + address: Address + chainId: number + transactionHash: string + status: string + createdAt: number + settledAt: number + amount: number + token?: TokenDetails +} - // merge deposits and withdrawals and sort them by date - const transactions = [ - ...deposits, - ...withdrawals, - ...(isTestnetMode - ? combinedCctpTestnetTransfers - : combinedCctpMainnetTransfers), - ...oftTransfers - ].flat() +enum TransferType { + ETH_DEPOSIT = 'eth_deposit', + ETH_WITHDRAWAL = 'eth_withdrawal', + ERC20_DEPOSIT = 'erc20_deposit', + ERC20_WITHDRAWAL = 'erc20_withdrawal' +} - return { - data: transactions, - loading: - isLoadingAccountType || - depositsLoading || - withdrawalsLoading || - cctpLoading || - oftLoading, - error: depositsError ?? withdrawalsError, - failedChainPairs: failedChainPairs || [] - } +export enum TransferStatus { + // Deposit initiated on Parent, awaiting finalization on Child + PENDING_CHILD_EXECUTION = 'PENDING_CHILD_EXECUTION', + // Withdrawal initiated on Child, awaiting claim on Parent + PENDING_PARENT_EXECUTION = 'PENDING_PARENT_EXECUTION', + // Deposit finalized on Child + COMPLETED = 'COMPLETED', + // Withdrawal awaiting user to claim it (has been confirmed in outbox) + READY_TO_CLAIM = 'READY_TO_CLAIM', + // Withdrawal claimed on Parent + CLAIMED = 'CLAIMED', + // Retryable failed/expired + FAILED = 'FAILED', + // Error cases + ERROR_TOKEN_LOOKUP = 'ERROR_TOKEN_LOOKUP', + ERROR_CLAIM_CHECK = 'ERROR_CLAIM_CHECK' } -/** - * Maps additional info to previously fetches transaction history, starting with the earliest data. - * This is done in small batches to safely meet RPC limits. - */ -export const useTransactionHistory = ( - address: Address | undefined, - // TODO: look for a solution to this. It's used for now so that useEffect that handles pagination runs only a single instance. - { runFetcher = false } = {} -): UseTransactionHistoryResult => { - const [isTestnetMode] = useIsTestnetMode() - const { chain } = useNetwork() - const { isSmartContractWallet, isLoading: isLoadingAccountType } = - useAccountType() - const [{ txHistory: isTxHistoryEnabled }] = useArbQueryParams() - const { connector } = useAccount() - // max number of transactions mapped in parallel - const MAX_BATCH_SIZE = 3 - // Pause fetching after specified number of days. User can resume fetching to get another batch. - const PAUSE_SIZE_DAYS = 30 - - const [fetching, setFetching] = useState(true) - const [pauseCount, setPauseCount] = useState(0) - - const { - data, - loading: isLoadingTxsWithoutStatus, - error, - failedChainPairs - } = useTransactionHistoryWithoutStatuses(address) - - const getCacheKey = useCallback( - (pageNumber: number, prevPageTxs: MergedTransaction[]) => { - if (prevPageTxs) { - if (prevPageTxs.length === 0) { - // THIS is the last page - return null - } - } - - return address && !isLoadingTxsWithoutStatus && !isLoadingAccountType - ? (['complete_tx_list', address, pageNumber, data] as const) - : null - }, - [address, isLoadingTxsWithoutStatus, data, isLoadingAccountType] - ) +type IndexerTransaction = { + sourceChain: IndexerPartialTransaction + destinationChain: IndexerPartialTransaction + parentChain: IndexerPartialTransaction + childChain: IndexerPartialTransaction + transferType: TransferType +} - const depositsFromCache = useMemo(() => { - if (isLoadingAccountType || !chain || !isTxHistoryEnabled) { +export const useTransactionHistory = (address: Address | undefined) => { + const { data, isLoading } = useSWR( + address ? [address, 'useTransactionHistory'] : null, + ([_address]) => { return [] } - return getDepositsWithoutStatusesFromCache(address) - .filter(tx => isNetwork(tx.parentChainId).isTestnet === isTestnetMode) - .filter(tx => { - const chainPairExists = getMultiChainFetchList().some(chainPair => { - return ( - chainPair.parentChainId === tx.parentChainId && - chainPair.childChainId === tx.childChainId - ) - }) - - if (!chainPairExists) { - // chain pair doesn't exist in the fetch list but exists in cached transactions - // this could happen if user made a transfer with a custom Orbit chain and then removed the network - // we don't want to include these txs as it would cause tx history errors - return false - } - - if (isSmartContractWallet) { - // only include txs for the connected network - return tx.parentChainId === chain.id - } - return true - }) - }, [ - address, - isTestnetMode, - isLoadingAccountType, - isSmartContractWallet, - chain, - isTxHistoryEnabled - ]) - - const { - data: txPages, - error: txPagesError, - size: page, - setSize: setPage, - mutate: mutateTxPages, - isValidating, - isLoading: isLoadingFirstPage - } = useSWRInfinite( - getCacheKey, - ([, , _page, _data]) => { - // we get cached data and dedupe here because we need to ensure _data never mutates - // otherwise, if we added a new tx to cache, it would return a new reference and cause the SWR key to update, resulting in refetching - const dataWithCache = [..._data, ...depositsFromCache] - - // duplicates may occur when txs are taken from the local storage - // we don't use Set because it wouldn't dedupe objects with different reference (we fetch them from different sources) - const dedupedTransactions = dedupeTransactions(dataWithCache).sort( - sortByTimestampDescending - ) - - const startIndex = _page * MAX_BATCH_SIZE - const endIndex = startIndex + MAX_BATCH_SIZE - - return Promise.all( - dedupedTransactions - .slice(startIndex, endIndex) - .map(transformTransaction) - ) - }, - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateIfStale: false, - shouldRetryOnError: false, - refreshWhenOffline: false, - refreshWhenHidden: false, - revalidateFirstPage: false, - keepPreviousData: true, - dedupingInterval: 1_000_000 - } - ) - - // based on an example from SWR - // https://swr.vercel.app/examples/infinite-loading - const isLoadingMore = - page > 0 && - typeof txPages !== 'undefined' && - typeof txPages[page - 1] === 'undefined' - - const completed = - !isLoadingFirstPage && - typeof txPages !== 'undefined' && - data.length === txPages.flat().length - - // transfers initiated by the user during the current session - // we store it separately as there are a lot of side effects when mutating SWRInfinite - const { data: newTransactionsData, mutate: mutateNewTransactionsData } = - useSWRImmutable( - address ? ['new_tx_list', address] : null - ) - - const transactions: MergedTransaction[] = useMemo(() => { - const txs = [...(newTransactionsData || []), ...(txPages || [])].flat() - // make sure txs are for the current account, we can have a mismatch when switching accounts for a bit - return txs.filter(tx => - [tx.sender?.toLowerCase(), tx.destination?.toLowerCase()].includes( - address?.toLowerCase() - ) - ) - }, [newTransactionsData, txPages, address]) - - const addPendingTransaction = useCallback( - (tx: MergedTransaction) => { - if (!isTxPending(tx)) { - return - } - - mutateNewTransactionsData(currentNewTransactions => { - if (!currentNewTransactions) { - return [tx] - } - - return [tx, ...currentNewTransactions] - }) - }, - [mutateNewTransactionsData] ) - const updateCachedTransaction = useCallback( - (newTx: MergedTransaction) => { - // check if tx is a new transaction initiated by the user, and update it - const foundInNewTransactions = - typeof newTransactionsData?.find(oldTx => - isSameTransaction(oldTx, newTx) - ) !== 'undefined' - - if (foundInNewTransactions) { - // replace the existing tx with the new tx - mutateNewTransactionsData(txs => - txs?.map(oldTx => { - return { ...(isSameTransaction(oldTx, newTx) ? newTx : oldTx) } - }) - ) - return - } - - // tx not found in the new user initiated transaction list - // look in the paginated historical data - mutateTxPages(prevTxPages => { - if (!prevTxPages) { - return - } - - let pageNumberToUpdate = 0 - - // search cache for the tx to update - while ( - !prevTxPages[pageNumberToUpdate]?.find(oldTx => - isSameTransaction(oldTx, newTx) - ) - ) { - pageNumberToUpdate++ - - if (pageNumberToUpdate > prevTxPages.length) { - // tx not found - return prevTxPages - } - } - - const oldPageToUpdate = prevTxPages[pageNumberToUpdate] - - if (!oldPageToUpdate) { - return prevTxPages - } - - // replace the old tx with the new tx - const updatedPage = oldPageToUpdate.map(oldTx => { - return isSameTransaction(oldTx, newTx) ? newTx : oldTx - }) - - // all old pages including the new updated page - const newTxPages = [ - ...prevTxPages.slice(0, pageNumberToUpdate), - updatedPage, - ...prevTxPages.slice(pageNumberToUpdate + 1) - ] - - return newTxPages - }, false) - }, - [mutateNewTransactionsData, mutateTxPages, newTransactionsData] - ) - - const updatePendingTransaction = useCallback( - async (tx: MergedTransaction) => { - if (!isTxPending(tx)) { - // if not pending we don't need to check for status, we accept whatever status is passed in - updateCachedTransaction(tx) - return - } - - if (isTeleportTx(tx)) { - const updatedTeleportTransfer = await getUpdatedTeleportTransfer(tx) - updateCachedTransaction(updatedTeleportTransfer) - return - } - - if (isOftTransfer(tx)) { - const updatedOftTransfer = await getUpdatedOftTransfer(tx) - updateCachedTransaction(updatedOftTransfer) - return - } - - if (tx.isCctp) { - const updatedCctpTransfer = await getUpdatedCctpTransfer(tx) - updateCachedTransaction(updatedCctpTransfer) - return - } - - // ETH or token withdrawal - if (tx.isWithdrawal) { - const updatedWithdrawal = await getUpdatedWithdrawal(tx) - updateCachedTransaction(updatedWithdrawal) - return - } - - const isDifferentDestinationAddress = isCustomDestinationAddressTx(tx) - - // ETH deposit to the same address - if (tx.assetType === AssetType.ETH && !isDifferentDestinationAddress) { - const updatedEthDeposit = await getUpdatedEthDeposit(tx) - updateCachedTransaction(updatedEthDeposit) - return - } - - // Token deposit or ETH deposit to a different destination address - const updatedRetryableDeposit = await getUpdatedRetryableDeposit(tx) - updateCachedTransaction(updatedRetryableDeposit) - }, - [updateCachedTransaction] - ) - - useEffect(() => { - if (!runFetcher || !connector) { - return - } - connector.on('change', e => { - // reset state on account change - if (e.account) { - setPage(1) - setPauseCount(0) - setFetching(true) - } - }) - }, [connector, runFetcher, setPage]) - - useEffect(() => { - if (!txPages || !fetching || !runFetcher || isValidating) { - return - } - - const firstPage = txPages[0] - const lastPage = txPages[txPages.length - 1] - - if (!firstPage || !lastPage) { - return - } - - // if a full page is fetched, we need to fetch more - const shouldFetchNextPage = lastPage.length === MAX_BATCH_SIZE - - if (!shouldFetchNextPage) { - setFetching(false) - return - } - - const newestTx = firstPage[0] - const oldestTx = lastPage[lastPage.length - 1] - - if (!newestTx || !oldestTx) { - return - } - - const oldestTxDaysAgo = dayjs().diff(dayjs(oldestTx.createdAt ?? 0), 'days') - - const nextPauseThresholdDays = (pauseCount + 1) * PAUSE_SIZE_DAYS - const shouldPause = oldestTxDaysAgo >= nextPauseThresholdDays - - if (shouldPause) { - pause() - setPauseCount(prevPauseCount => prevPauseCount + 1) - return - } - - // make sure we don't over-fetch - if (page === txPages.length) { - setPage(prevPage => prevPage + 1) - } - }, [txPages, setPage, page, pauseCount, fetching, runFetcher, isValidating]) - - useEffect(() => { - if (typeof error !== 'undefined') { - console.warn(error) - captureSentryErrorWithExtraData({ - error, - originFunction: 'useTransactionHistoryWithoutStatuses' - }) - } - - if (typeof txPagesError !== 'undefined') { - console.warn(txPagesError) - captureSentryErrorWithExtraData({ - error: txPagesError, - originFunction: 'useTransactionHistory' - }) - } - }, [error, txPagesError]) - - function pause() { - setFetching(false) - } - - function resume() { - setFetching(true) - setPage(prevPage => prevPage + 1) - } - - if (isLoadingTxsWithoutStatus || error) { - return { - transactions: newTransactionsData || [], - loading: isLoadingTxsWithoutStatus, - error, - failedChainPairs: [], - completed: true, - pause, - resume, - addPendingTransaction, - updatePendingTransaction - } - } - - return { - transactions, - loading: isLoadingFirstPage || isLoadingMore, - completed, - error: txPagesError ?? error, - failedChainPairs, - pause, - resume, - addPendingTransaction, - updatePendingTransaction - } + return { transactions: data || [], isLoading } } From 815e2fc09067ee161761752eb50490cf53c31158 Mon Sep 17 00:00:00 2001 From: Bartek Date: Tue, 6 May 2025 15:43:22 +0200 Subject: [PATCH 2/2] more changes --- .../src/hooks/useTransactionHistory.ts | 808 +++++++++++++++++- 1 file changed, 766 insertions(+), 42 deletions(-) diff --git a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts index 0a2702d495..fbbd77c10e 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts @@ -1,13 +1,22 @@ -import { getChains, getChildChainIds } from '../util/networks' +import { useAccount, useNetwork } from 'wagmi' +import useSWRImmutable from 'swr/immutable' +import useSWRInfinite from 'swr/infinite' +import { useCallback, useEffect, useMemo, useState } from 'react' +import dayjs from 'dayjs' + +import { getChains, getChildChainIds, isNetwork } from '../util/networks' import { ChainId } from '../types/ChainId' +import { fetchWithdrawals } from '../util/withdrawals/fetchWithdrawals' +import { fetchDeposits } from '../util/deposits/fetchDeposits' import { AssetType, L2ToL1EventResultPlus, WithdrawalInitiated } from './arbTokenBridge.types' -import { Transaction } from '../types/Transactions' +import { isTeleportTx, Transaction } from '../types/Transactions' import { MergedTransaction } from '../state/app/state' import { + isCustomDestinationAddressTx, normalizeTimestamp, transformDeposit, transformWithdrawal @@ -21,20 +30,43 @@ import { } from '../util/withdrawals/helpers' import { WithdrawalFromSubgraph } from '../util/withdrawals/fetchWithdrawalsFromSubgraph' import { updateAdditionalDepositData } from '../util/deposits/helpers' +import { useCctpFetching } from '../state/cctpState' import { + getDepositsWithoutStatusesFromCache, + getUpdatedCctpTransfer, + getUpdatedEthDeposit, + getUpdatedTeleportTransfer, + getUpdatedRetryableDeposit, + getUpdatedWithdrawal, isCctpTransfer, + isSameTransaction, + isTxPending, isOftTransfer } from '../components/TransactionHistory/helpers' +import { useIsTestnetMode } from './useIsTestnetMode' +import { useAccountType } from './useAccountType' +import { + shouldIncludeReceivedTxs, + shouldIncludeSentTxs +} from '../util/SubgraphUtils' +import { isValidTeleportChainPair } from '@/token-bridge-sdk/teleport' import { getProviderForChainId } from '@/token-bridge-sdk/utils' import { Address } from '../util/AddressUtils' -import { TeleportFromSubgraph } from '../util/teleports/fetchTeleports' +import { + TeleportFromSubgraph, + fetchTeleports +} from '../util/teleports/fetchTeleports' import { isTransferTeleportFromSubgraph, transformTeleportFromSubgraph } from '../util/teleports/helpers' +import { captureSentryErrorWithExtraData } from '../util/SentryUtils' +import { useArbQueryParams } from './useArbQueryParams' import { + getUpdatedOftTransfer, LayerZeroTransaction, - updateAdditionalLayerZeroData + updateAdditionalLayerZeroData, + useOftTransactionHistory } from './useOftTransactionHistory' import { create } from 'zustand' import useSWR from 'swr' @@ -46,7 +78,6 @@ export type UseTransactionHistoryResult = { completed: boolean error: unknown failedChainPairs: ChainPair[] - pause: () => void resume: () => void addPendingTransaction: (tx: MergedTransaction) => void updatePendingTransaction: (tx: MergedTransaction) => Promise @@ -227,39 +258,227 @@ function dedupeTransactions(txs: Transfer[]) { ) } -function indexerTransactionToMergedTransaction( - tx: IndexerTransaction -): MergedTransaction { - return { - sender: tx.sourceChain.address, - destination: tx.destinationChain.address, - direction: [TransferType.ETH_DEPOSIT, TransferType.ERC20_DEPOSIT].includes( - tx.transferType +/** + * Fetches transaction history only for deposits and withdrawals, without their statuses. + */ +const useTransactionHistoryWithoutStatuses = ( + address: Address | undefined, + enabled: boolean +) => { + const { chain } = useNetwork() + const [isTestnetMode] = useIsTestnetMode() + const { isSmartContractWallet, isLoading: isLoadingAccountType } = + useAccountType() + const [{ txHistory: isTxHistoryEnabled }] = useArbQueryParams() + const forceFetchReceived = useForceFetchReceived( + state => state.forceFetchReceived + ) + + const cctpTransfersMainnet = useCctpFetching({ + walletAddress: address, + l1ChainId: ChainId.Ethereum, + l2ChainId: ChainId.ArbitrumOne, + pageNumber: 0, + pageSize: 1000, + type: 'all' + }) + + const cctpTransfersTestnet = useCctpFetching({ + walletAddress: address, + l1ChainId: ChainId.Sepolia, + l2ChainId: ChainId.ArbitrumSepolia, + pageNumber: 0, + pageSize: 1000, + type: 'all' + }) + + const combinedCctpMainnetTransfers = [ + ...(cctpTransfersMainnet.deposits?.completed || []), + ...(cctpTransfersMainnet.withdrawals?.completed || []), + ...(cctpTransfersMainnet.deposits?.pending || []), + ...(cctpTransfersMainnet.withdrawals?.pending || []) + ] + + const combinedCctpTestnetTransfers = [ + ...(cctpTransfersTestnet.deposits?.completed || []), + ...(cctpTransfersTestnet.withdrawals?.completed || []), + ...(cctpTransfersTestnet.deposits?.pending || []), + ...(cctpTransfersTestnet.withdrawals?.pending || []) + ] + + const cctpLoading = + cctpTransfersMainnet.isLoadingDeposits || + cctpTransfersMainnet.isLoadingWithdrawals || + cctpTransfersTestnet.isLoadingDeposits || + cctpTransfersTestnet.isLoadingWithdrawals + + const { transactions: oftTransfers, isLoading: oftLoading } = + useOftTransactionHistory({ + walletAddress: address, + isTestnet: isTestnetMode + }) + + const { data: failedChainPairs, mutate: addFailedChainPair } = + useSWRImmutable( + address ? ['failed_chain_pairs', address] : null ) - ? 'deposit-l1' - : 'withdraw', - status: 'pending', - createdAt: tx.sourceChain.createdAt, - resolvedAt: tx.destinationChain.settledAt, - txId: tx.sourceChain.transactionHash, - asset: tx.sourceChain.token ? tx.sourceChain.token.symbol : 'ETH', - assetType: tx.sourceChain.token ? AssetType.ERC20 : AssetType.ETH, - value: tx.sourceChain.amount.toString(), - uniqueId: BigNumber.from(0), - isWithdrawal: [ - TransferType.ETH_WITHDRAWAL, - TransferType.ERC20_WITHDRAWAL - ].includes(tx.transferType), - blockNum: 0, - tokenAddress: tx.sourceChain.token?.address ?? null, - isCctp: false, - isOft: false, - nodeBlockDeadline: 0, - depositStatus: 1, - parentChainId: tx.parentChain.chainId, - childChainId: tx.childChain.chainId, - sourceChainId: tx.sourceChain.chainId, - destinationChainId: tx.destinationChain.chainId + + const fetcher = useCallback( + (type: 'deposits' | 'withdrawals') => { + if (!chain) { + return [] + } + + const fetcherFn = type === 'deposits' ? fetchDeposits : fetchWithdrawals + + return Promise.all( + getMultiChainFetchList() + .filter(chainPair => { + if (isSmartContractWallet) { + // only fetch txs from the connected network + return [chainPair.parentChainId, chainPair.childChainId].includes( + chain.id + ) + } + + return ( + isNetwork(chainPair.parentChainId).isTestnet === isTestnetMode + ) + }) + .map(async chainPair => { + // SCW address is tied to a specific network + // that's why we need to limit shown txs either to sent or received funds + // otherwise we'd display funds for a different network, which could be someone else's account + const isConnectedToParentChain = + chainPair.parentChainId === chain.id + + const includeSentTxs = shouldIncludeSentTxs({ + type, + isSmartContractWallet, + isConnectedToParentChain + }) + + const includeReceivedTxs = shouldIncludeReceivedTxs({ + type, + isSmartContractWallet, + isConnectedToParentChain + }) + try { + // early check for fetching teleport + if ( + isValidTeleportChainPair({ + sourceChainId: chainPair.parentChainId, + destinationChainId: chainPair.childChainId + }) + ) { + // teleporter does not support withdrawals + if (type === 'withdrawals') return [] + + return await fetchTeleports({ + sender: includeSentTxs ? address : undefined, + receiver: includeReceivedTxs ? address : undefined, + parentChainProvider: getProviderForChainId( + chainPair.parentChainId + ), + childChainProvider: getProviderForChainId( + chainPair.childChainId + ), + pageNumber: 0, + pageSize: 1000 + }) + } + + // else, fetch deposits or withdrawals + return await fetcherFn({ + sender: includeSentTxs ? address : undefined, + receiver: includeReceivedTxs ? address : undefined, + l1Provider: getProviderForChainId(chainPair.parentChainId), + l2Provider: getProviderForChainId(chainPair.childChainId), + pageNumber: 0, + pageSize: 1000, + forceFetchReceived + }) + } catch { + addFailedChainPair(prevFailedChainPairs => { + if (!prevFailedChainPairs) { + return [chainPair] + } + if ( + typeof prevFailedChainPairs.find( + prevPair => + prevPair.parentChainId === chainPair.parentChainId && + prevPair.childChainId === chainPair.childChainId + ) !== 'undefined' + ) { + // already added + return prevFailedChainPairs + } + + return [...prevFailedChainPairs, chainPair] + }) + + return [] + } + }) + ) + }, + [ + address, + isTestnetMode, + addFailedChainPair, + isSmartContractWallet, + chain, + forceFetchReceived + ] + ) + + const shouldFetch = + address && chain && !isLoadingAccountType && isTxHistoryEnabled && enabled + + const { + data: depositsData, + error: depositsError, + isLoading: depositsLoading + } = useSWRImmutable( + shouldFetch ? ['tx_list', 'deposits', address, isTestnetMode] : null, + () => fetcher('deposits') + ) + + const { + data: withdrawalsData, + error: withdrawalsError, + isLoading: withdrawalsLoading + } = useSWRImmutable( + shouldFetch + ? ['tx_list', 'withdrawals', address, isTestnetMode, forceFetchReceived] + : null, + () => fetcher('withdrawals') + ) + + const deposits = (depositsData || []).flat() + + const withdrawals = (withdrawalsData || []).flat() + + // merge deposits and withdrawals and sort them by date + const transactions = [ + ...deposits, + ...withdrawals, + ...(isTestnetMode + ? combinedCctpTestnetTransfers + : combinedCctpMainnetTransfers), + ...oftTransfers + ].flat() + + return { + data: transactions, + loading: + isLoadingAccountType || + depositsLoading || + withdrawalsLoading || + cctpLoading || + oftLoading, + error: depositsError ?? withdrawalsError, + failedChainPairs: failedChainPairs || [] } } @@ -314,13 +533,518 @@ type IndexerTransaction = { transferType: TransferType } -export const useTransactionHistory = (address: Address | undefined) => { - const { data, isLoading } = useSWR( - address ? [address, 'useTransactionHistory'] : null, - ([_address]) => { +const useIndexerTransactionHistory = ( + address: Address | undefined +): Omit< + UseTransactionHistoryResult, + 'addPendingTransaction' | 'updatePendingTransaction' +> => { + const getCacheKey = useCallback( + (pageNumber: number, prevPageTxs: MergedTransaction[]) => { + if (prevPageTxs) { + if (prevPageTxs.length === 0) { + // THIS is the last page + return null + } + } + + return address ? ([address, pageNumber] as const) : null + }, + [address] + ) + + const { + data, + error, + size: page, + setSize: setPage, + isValidating, + isLoading: isLoadingFirstPage + } = useSWRInfinite(getCacheKey, ([]) => []) + + const mappedIndexerTransactions = useMemo(() => { + if (!data) { return [] } + return data.flat().map(indexerTransactionToMergedTransaction) + }, [data]) + + function resume() { + setPage(prevPage => prevPage + 1) + } + + const completed = + !isLoadingFirstPage && + typeof data !== 'undefined' && + data.length === data.flat().length + + return { + transactions: mappedIndexerTransactions, + loading: isLoadingFirstPage || isValidating, + error, + completed, + resume, + // no single pair will fail because we don't interact with individual RPCs + failedChainPairs: [] + } +} + +function indexerTransactionToMergedTransaction( + tx: IndexerTransaction +): MergedTransaction { + return { + sender: tx.sourceChain.address, + destination: tx.destinationChain.address, + direction: [TransferType.ETH_DEPOSIT, TransferType.ERC20_DEPOSIT].includes( + tx.transferType + ) + ? 'deposit-l1' + : 'withdraw', + status: 'pending', + createdAt: tx.sourceChain.createdAt, + resolvedAt: tx.destinationChain.settledAt, + txId: tx.sourceChain.transactionHash, + asset: tx.sourceChain.token ? tx.sourceChain.token.symbol : 'ETH', + assetType: tx.sourceChain.token ? AssetType.ERC20 : AssetType.ETH, + value: tx.sourceChain.amount.toString(), + uniqueId: BigNumber.from(0), + isWithdrawal: [ + TransferType.ETH_WITHDRAWAL, + TransferType.ERC20_WITHDRAWAL + ].includes(tx.transferType), + blockNum: 0, + tokenAddress: tx.sourceChain.token?.address ?? null, + isCctp: false, + isOft: false, + nodeBlockDeadline: 0, + depositStatus: 1, + parentChainId: tx.parentChain.chainId, + childChainId: tx.childChain.chainId, + sourceChainId: tx.sourceChain.chainId, + destinationChainId: tx.destinationChain.chainId + } +} + +/** + * Maps additional info to previously fetches transaction history, starting with the earliest data. + * This is done in small batches to safely meet RPC limits. + */ +const useLegacyTransactionHistory = ( + address: Address | undefined, + // TODO: look for a solution to this. It's used for now so that useEffect that handles pagination runs only a single instance. + { runFetcher = false, enabled = false } = {} +): UseTransactionHistoryResult => { + const [isTestnetMode] = useIsTestnetMode() + const { chain } = useNetwork() + const { isSmartContractWallet, isLoading: isLoadingAccountType } = + useAccountType() + const [{ txHistory: isTxHistoryEnabled }] = useArbQueryParams() + const { connector } = useAccount() + // max number of transactions mapped in parallel + const MAX_BATCH_SIZE = 3 + // Pause fetching after specified number of days. User can resume fetching to get another batch. + const PAUSE_SIZE_DAYS = 30 + + const [fetching, setFetching] = useState(true) + const [pauseCount, setPauseCount] = useState(0) + + const { + data, + loading: isLoadingTxsWithoutStatus, + error, + failedChainPairs + } = useTransactionHistoryWithoutStatuses(address, enabled) + + const getCacheKey = useCallback( + (pageNumber: number, prevPageTxs: MergedTransaction[]) => { + if (!enabled) { + return null + } + + if (prevPageTxs) { + if (prevPageTxs.length === 0) { + // THIS is the last page + return null + } + } + + return address && !isLoadingTxsWithoutStatus && !isLoadingAccountType + ? (['complete_tx_list', address, pageNumber, data] as const) + : null + }, + [address, isLoadingTxsWithoutStatus, data, isLoadingAccountType, enabled] + ) + + const depositsFromCache = useMemo(() => { + if (isLoadingAccountType || !chain || !isTxHistoryEnabled) { + return [] + } + return getDepositsWithoutStatusesFromCache(address) + .filter(tx => isNetwork(tx.parentChainId).isTestnet === isTestnetMode) + .filter(tx => { + const chainPairExists = getMultiChainFetchList().some(chainPair => { + return ( + chainPair.parentChainId === tx.parentChainId && + chainPair.childChainId === tx.childChainId + ) + }) + + if (!chainPairExists) { + // chain pair doesn't exist in the fetch list but exists in cached transactions + // this could happen if user made a transfer with a custom Orbit chain and then removed the network + // we don't want to include these txs as it would cause tx history errors + return false + } + + if (isSmartContractWallet) { + // only include txs for the connected network + return tx.parentChainId === chain.id + } + return true + }) + }, [ + address, + isTestnetMode, + isLoadingAccountType, + isSmartContractWallet, + chain, + isTxHistoryEnabled + ]) + + const { + data: txPages, + error: txPagesError, + size: page, + setSize: setPage, + mutate: mutateTxPages, + isValidating, + isLoading: isLoadingFirstPage + } = useSWRInfinite( + getCacheKey, + ([, , _page, _data]) => { + // we get cached data and dedupe here because we need to ensure _data never mutates + // otherwise, if we added a new tx to cache, it would return a new reference and cause the SWR key to update, resulting in refetching + const dataWithCache = [..._data, ...depositsFromCache] + + // duplicates may occur when txs are taken from the local storage + // we don't use Set because it wouldn't dedupe objects with different reference (we fetch them from different sources) + const dedupedTransactions = dedupeTransactions(dataWithCache).sort( + sortByTimestampDescending + ) + + const startIndex = _page * MAX_BATCH_SIZE + const endIndex = startIndex + MAX_BATCH_SIZE + + return Promise.all( + dedupedTransactions + .slice(startIndex, endIndex) + .map(transformTransaction) + ) + }, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + shouldRetryOnError: false, + refreshWhenOffline: false, + refreshWhenHidden: false, + revalidateFirstPage: false, + keepPreviousData: true, + dedupingInterval: 1_000_000 + } + ) + + // based on an example from SWR + // https://swr.vercel.app/examples/infinite-loading + const isLoadingMore = + page > 0 && + typeof txPages !== 'undefined' && + typeof txPages[page - 1] === 'undefined' + + const completed = + !isLoadingFirstPage && + typeof txPages !== 'undefined' && + data.length === txPages.flat().length + + // transfers initiated by the user during the current session + // we store it separately as there are a lot of side effects when mutating SWRInfinite + const { data: newTransactionsData, mutate: mutateNewTransactionsData } = + useSWRImmutable( + address ? ['new_tx_list', address] : null + ) + + const transactions: MergedTransaction[] = useMemo(() => { + const txs = [...(newTransactionsData || []), ...(txPages || [])].flat() + // make sure txs are for the current account, we can have a mismatch when switching accounts for a bit + return txs.filter(tx => + [tx.sender?.toLowerCase(), tx.destination?.toLowerCase()].includes( + address?.toLowerCase() + ) + ) + }, [newTransactionsData, txPages, address]) + + const addPendingTransaction = useCallback( + (tx: MergedTransaction) => { + if (!isTxPending(tx)) { + return + } + + mutateNewTransactionsData(currentNewTransactions => { + if (!currentNewTransactions) { + return [tx] + } + + return [tx, ...currentNewTransactions] + }) + }, + [mutateNewTransactionsData] + ) + + const updateCachedTransaction = useCallback( + (newTx: MergedTransaction) => { + // check if tx is a new transaction initiated by the user, and update it + const foundInNewTransactions = + typeof newTransactionsData?.find(oldTx => + isSameTransaction(oldTx, newTx) + ) !== 'undefined' + + if (foundInNewTransactions) { + // replace the existing tx with the new tx + mutateNewTransactionsData(txs => + txs?.map(oldTx => { + return { ...(isSameTransaction(oldTx, newTx) ? newTx : oldTx) } + }) + ) + return + } + + // tx not found in the new user initiated transaction list + // look in the paginated historical data + mutateTxPages(prevTxPages => { + if (!prevTxPages) { + return + } + + let pageNumberToUpdate = 0 + + // search cache for the tx to update + while ( + !prevTxPages[pageNumberToUpdate]?.find(oldTx => + isSameTransaction(oldTx, newTx) + ) + ) { + pageNumberToUpdate++ + + if (pageNumberToUpdate > prevTxPages.length) { + // tx not found + return prevTxPages + } + } + + const oldPageToUpdate = prevTxPages[pageNumberToUpdate] + + if (!oldPageToUpdate) { + return prevTxPages + } + + // replace the old tx with the new tx + const updatedPage = oldPageToUpdate.map(oldTx => { + return isSameTransaction(oldTx, newTx) ? newTx : oldTx + }) + + // all old pages including the new updated page + const newTxPages = [ + ...prevTxPages.slice(0, pageNumberToUpdate), + updatedPage, + ...prevTxPages.slice(pageNumberToUpdate + 1) + ] + + return newTxPages + }, false) + }, + [mutateNewTransactionsData, mutateTxPages, newTransactionsData] ) - return { transactions: data || [], isLoading } + const updatePendingTransaction = useCallback( + async (tx: MergedTransaction) => { + if (!isTxPending(tx)) { + // if not pending we don't need to check for status, we accept whatever status is passed in + updateCachedTransaction(tx) + return + } + + if (isTeleportTx(tx)) { + const updatedTeleportTransfer = await getUpdatedTeleportTransfer(tx) + updateCachedTransaction(updatedTeleportTransfer) + return + } + + if (isOftTransfer(tx)) { + const updatedOftTransfer = await getUpdatedOftTransfer(tx) + updateCachedTransaction(updatedOftTransfer) + return + } + + if (tx.isCctp) { + const updatedCctpTransfer = await getUpdatedCctpTransfer(tx) + updateCachedTransaction(updatedCctpTransfer) + return + } + + // ETH or token withdrawal + if (tx.isWithdrawal) { + const updatedWithdrawal = await getUpdatedWithdrawal(tx) + updateCachedTransaction(updatedWithdrawal) + return + } + + const isDifferentDestinationAddress = isCustomDestinationAddressTx(tx) + + // ETH deposit to the same address + if (tx.assetType === AssetType.ETH && !isDifferentDestinationAddress) { + const updatedEthDeposit = await getUpdatedEthDeposit(tx) + updateCachedTransaction(updatedEthDeposit) + return + } + + // Token deposit or ETH deposit to a different destination address + const updatedRetryableDeposit = await getUpdatedRetryableDeposit(tx) + updateCachedTransaction(updatedRetryableDeposit) + }, + [updateCachedTransaction] + ) + + useEffect(() => { + if (!runFetcher || !connector) { + return + } + connector.on('change', e => { + // reset state on account change + if (e.account) { + setPage(1) + setPauseCount(0) + setFetching(true) + } + }) + }, [connector, runFetcher, setPage]) + + useEffect(() => { + if (!txPages || !fetching || !runFetcher || isValidating) { + return + } + + const firstPage = txPages[0] + const lastPage = txPages[txPages.length - 1] + + if (!firstPage || !lastPage) { + return + } + + // if a full page is fetched, we need to fetch more + const shouldFetchNextPage = lastPage.length === MAX_BATCH_SIZE + + if (!shouldFetchNextPage) { + setFetching(false) + return + } + + const newestTx = firstPage[0] + const oldestTx = lastPage[lastPage.length - 1] + + if (!newestTx || !oldestTx) { + return + } + + const oldestTxDaysAgo = dayjs().diff(dayjs(oldestTx.createdAt ?? 0), 'days') + + const nextPauseThresholdDays = (pauseCount + 1) * PAUSE_SIZE_DAYS + const shouldPause = oldestTxDaysAgo >= nextPauseThresholdDays + + if (shouldPause) { + pause() + setPauseCount(prevPauseCount => prevPauseCount + 1) + return + } + + // make sure we don't over-fetch + if (page === txPages.length) { + setPage(prevPage => prevPage + 1) + } + }, [txPages, setPage, page, pauseCount, fetching, runFetcher, isValidating]) + + useEffect(() => { + if (typeof error !== 'undefined') { + console.warn(error) + captureSentryErrorWithExtraData({ + error, + originFunction: 'useTransactionHistoryWithoutStatuses' + }) + } + + if (typeof txPagesError !== 'undefined') { + console.warn(txPagesError) + captureSentryErrorWithExtraData({ + error: txPagesError, + originFunction: 'useTransactionHistory' + }) + } + }, [error, txPagesError]) + + function pause() { + setFetching(false) + } + + function resume() { + setFetching(true) + setPage(prevPage => prevPage + 1) + } + + if (isLoadingTxsWithoutStatus || error) { + return { + transactions: newTransactionsData || [], + loading: isLoadingTxsWithoutStatus, + error, + failedChainPairs: [], + completed: true, + resume, + addPendingTransaction, + updatePendingTransaction + } + } + + return { + transactions, + loading: isLoadingFirstPage || isLoadingMore, + completed, + error: txPagesError ?? error, + failedChainPairs, + resume, + addPendingTransaction, + updatePendingTransaction + } +} + +export const useTransactionHistory = ( + address: Address | undefined, + { runFetcher = false } = {} +): UseTransactionHistoryResult => { + const indexerData = useIndexerTransactionHistory(address) + + const isFallback = + indexerData.transactions.length === 0 && + typeof indexerData.error !== 'undefined' + + const fallbackData = useLegacyTransactionHistory(address, { + runFetcher, + enabled: isFallback + }) + + return isFallback + ? fallbackData + : { + ...indexerData, + // New transactions are handled by the legacy system + addPendingTransaction: fallbackData.addPendingTransaction, + updatePendingTransaction: fallbackData.updatePendingTransaction + } }