diff --git a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx index 2c05ec0da8..d29a88f753 100644 --- a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx @@ -28,6 +28,7 @@ import { TransactionsTableRow } from './TransactionsTableRow' import { EmptyTransactionHistory } from './EmptyTransactionHistory' import { MergedTransaction } from '../../state/app/state' import { useNativeCurrency } from '../../hooks/useNativeCurrency' +import { Loader } from '../common/atoms/Loader' export const BatchTransferNativeTokenTooltip = ({ children, @@ -139,10 +140,9 @@ export const TransactionHistoryTable = ( ) => { const { transactions, - loading, - completed, - error, - failedChainPairs, + senderData, + receiverData, + rawDataErroredChains, resume, selectedTabIndex, oldestTxTimeAgoString @@ -153,7 +153,7 @@ export const TransactionHistoryTable = ( const isTxHistoryEmpty = transactions.length === 0 const isPendingTab = selectedTabIndex === 0 - const paused = !loading && !completed + const paused = !senderData.loadingForSender && !senderData.completedForSender const contentWrapperRef = useRef(null) const tableRef = useRef(null) @@ -194,8 +194,11 @@ export const TransactionHistoryTable = ( if (isTxHistoryEmpty) { return ( - {loading ? ( + {senderData.loadingForSender ? (
- +
) : (
- + {receiverData.loadingForReceiver && ( + + Inbound transactions received from another address are + still being fetched... + + } + > + + + )} + Showing {transactions.length}{' '} {isPendingTab ? 'pending' : 'settled'} transactions made in{' '} @@ -230,7 +247,9 @@ export const TransactionHistoryTable = (
- {!completed && } + {!senderData.completedForSender && ( + + )}
)}
{pendingTokenDepositsCount > 0 && }
diff --git a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts index 428722771e..d3760f2be7 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts @@ -67,16 +67,34 @@ import { captureSentryErrorWithExtraData } from '../util/SentryUtils' export type UseTransactionHistoryResult = { transactions: MergedTransaction[] - loading: boolean - completed: boolean - error: unknown - failedChainPairs: ChainPair[] + senderData: { + senderTransactions: MergedTransaction[] + loadingForSender: boolean + completedForSender: boolean + errorForSender: unknown + } + receiverData: { + receiverTransactions: MergedTransaction[] + loadingForReceiver: boolean + completedForReceiver: boolean + errorForReceiver: unknown + } + rawDataErroredChains: ChainPair[] pause: () => void resume: () => void addPendingTransaction: (tx: MergedTransaction) => void updatePendingTransaction: (tx: MergedTransaction) => Promise } +type UseMappedTransactionHistoryResult = { + transactions: MergedTransaction[] + error: unknown + completed: boolean + loading: boolean + rawDataErroredChains: ChainPair[] + updatePendingTransaction: (tx: MergedTransaction) => Promise +} + export type ChainPair = { parentChainId: ChainId; childChainId: ChainId } export type Deposit = Transaction @@ -241,101 +259,65 @@ function dedupeTransactions(txs: Transfer[]) { ) } +async function getUpdatedPendingTransaction(tx: MergedTransaction) { + if (!isTxPending(tx)) { + // if not pending we don't need to check for status, we accept whatever status is passed in + return tx + } + + if (isTeleportTx(tx)) { + return getUpdatedTeleportTransfer(tx) + } + + if (tx.isCctp) { + return getUpdatedCctpTransfer(tx) + } + + // ETH or token withdrawal + if (tx.isWithdrawal) { + return getUpdatedWithdrawal(tx) + } + + const isDifferentDestinationAddress = isCustomDestinationAddressTx(tx) + + // ETH deposit to the same address + if (tx.assetType === AssetType.ETH && !isDifferentDestinationAddress) { + return getUpdatedEthDeposit(tx) + } + + // Token deposit or ETH deposit to a different destination address + return getUpdatedRetryableDeposit(tx) +} + /** * Fetches transaction history only for deposits and withdrawals, without their statuses. */ -const useTransactionHistoryWithoutStatuses = (address: Address | undefined) => { +const useRawTransactionHistory = ({ + address, + fetchFor, + shouldStartToFetch +}: { + address: Address | undefined + fetchFor: 'sender' | 'receiver' + shouldStartToFetch: boolean +}) => { const { chain } = useNetwork() const [isTestnetMode] = useIsTestnetMode() const { isSmartContractWallet, isLoading: isLoadingAccountType } = useAccountType() - // Check what type of CCTP (deposit, withdrawal or all) to fetch - // We need this because of Smart Contract Wallets - const cctpTypeToFetch = useCallback( - (chainPair: ChainPair): 'deposits' | 'withdrawals' | 'all' | undefined => { - if (isLoadingAccountType || !chain) { - return undefined - } - if (isSmartContractWallet) { - // fetch based on the connected network - if (chain.id === chainPair.parentChainId) { - return 'deposits' - } - if (chain.id === chainPair.childChainId) { - return 'withdrawals' - } - return undefined - } - // EOA - return isNetwork(chainPair.parentChainId).isTestnet === isTestnetMode - ? 'all' - : undefined - }, - [isSmartContractWallet, isLoadingAccountType, chain, isTestnetMode] - ) - - const cctpTransfersMainnet = useCctpFetching({ - walletAddress: address, - l1ChainId: ChainId.Ethereum, - l2ChainId: ChainId.ArbitrumOne, - pageNumber: 0, - pageSize: cctpTypeToFetch({ - parentChainId: ChainId.Ethereum, - childChainId: ChainId.ArbitrumOne - }) - ? 1000 - : 0, - type: - cctpTypeToFetch({ - parentChainId: ChainId.Ethereum, - childChainId: ChainId.ArbitrumOne - }) ?? 'all' - }) - - const cctpTransfersTestnet = useCctpFetching({ - walletAddress: address, - l1ChainId: ChainId.Sepolia, - l2ChainId: ChainId.ArbitrumSepolia, - pageNumber: 0, - pageSize: cctpTypeToFetch({ - parentChainId: ChainId.Sepolia, - childChainId: ChainId.ArbitrumSepolia - }) - ? 1000 - : 0, - type: - cctpTypeToFetch({ - parentChainId: ChainId.Sepolia, - childChainId: ChainId.ArbitrumSepolia - }) ?? 'all' - }) - - // TODO: Clean up this logic when introducing testnet/mainnet split - const combinedCctpTransfers = [ - ...(cctpTransfersMainnet.deposits?.completed || []), - ...(cctpTransfersMainnet.withdrawals?.completed || []), - ...(cctpTransfersTestnet.deposits?.completed || []), - ...(cctpTransfersTestnet.withdrawals?.completed || []), - ...(cctpTransfersMainnet.deposits?.pending || []), - ...(cctpTransfersMainnet.withdrawals?.pending || []), - ...(cctpTransfersTestnet.deposits?.pending || []), - ...(cctpTransfersTestnet.withdrawals?.pending || []) - ] - - const cctpLoading = - cctpTransfersMainnet.isLoadingDeposits || - cctpTransfersMainnet.isLoadingWithdrawals || - cctpTransfersTestnet.isLoadingDeposits || - cctpTransfersTestnet.isLoadingWithdrawals - - const { data: failedChainPairs, mutate: addFailedChainPair } = - useSWRImmutable( - address ? ['failed_chain_pairs', address] : null - ) + const { data: erroredChains, mutate: addErroredChain } = useSWRImmutable< + ChainPair[] + >(address ? ['errored_chains', address] : null) const fetcher = useCallback( - (type: 'deposits' | 'withdrawals') => { + ({ + type, + fetchFor + }: { + type: 'deposits' | 'withdrawals' + fetchFor: 'sender' | 'receiver' + }) => { if (!chain) { return [] } @@ -374,6 +356,11 @@ const useTransactionHistoryWithoutStatuses = (address: Address | undefined) => { isSmartContractWallet, isConnectedToParentChain }) + + const fetchForSender = fetchFor === 'sender' && includeSentTxs + const fetchForReceiver = + fetchFor === 'receiver' && includeReceivedTxs + try { // early check for fetching teleport if ( @@ -386,8 +373,8 @@ const useTransactionHistoryWithoutStatuses = (address: Address | undefined) => { if (type === 'withdrawals') return [] return await fetchTeleports({ - sender: includeSentTxs ? address : undefined, - receiver: includeReceivedTxs ? address : undefined, + sender: fetchForSender ? address : undefined, + receiver: fetchForReceiver ? address : undefined, parentChainProvider: getProviderForChainId( chainPair.parentChainId ), @@ -401,30 +388,30 @@ const useTransactionHistoryWithoutStatuses = (address: Address | undefined) => { // else, fetch deposits or withdrawals return await fetcherFn({ - sender: includeSentTxs ? address : undefined, - receiver: includeReceivedTxs ? address : undefined, + sender: fetchForSender ? address : undefined, + receiver: fetchForReceiver ? address : undefined, l1Provider: getProviderForChainId(chainPair.parentChainId), l2Provider: getProviderForChainId(chainPair.childChainId), pageNumber: 0, pageSize: 1000 }) } catch { - addFailedChainPair(prevFailedChainPairs => { - if (!prevFailedChainPairs) { + addErroredChain(prevErroredChains => { + if (!prevErroredChains) { return [chainPair] } if ( - typeof prevFailedChainPairs.find( - prevPair => - prevPair.parentChainId === chainPair.parentChainId && - prevPair.childChainId === chainPair.childChainId + typeof prevErroredChains.find( + prev => + prev.parentChainId === chainPair.parentChainId && + prev.childChainId === chainPair.childChainId ) !== 'undefined' ) { // already added - return prevFailedChainPairs + return prevErroredChains } - return [...prevFailedChainPairs, chainPair] + return [...prevErroredChains, chainPair] }) return [] @@ -432,18 +419,21 @@ const useTransactionHistoryWithoutStatuses = (address: Address | undefined) => { }) ) }, - [address, isTestnetMode, addFailedChainPair, isSmartContractWallet, chain] + [addErroredChain, address, chain, isSmartContractWallet, isTestnetMode] ) - const shouldFetch = address && chain && !isLoadingAccountType + const shouldFetch = + shouldStartToFetch && address && chain && !isLoadingAccountType const { data: depositsData, error: depositsError, isLoading: depositsLoading } = useSWRImmutable( - shouldFetch ? ['tx_list', 'deposits', address, isTestnetMode] : null, - () => fetcher('deposits') + shouldFetch + ? ['tx_list', 'deposits', address, isTestnetMode, fetchFor] + : null, + () => fetcher({ type: 'deposits', fetchFor }) ) const { @@ -451,59 +441,161 @@ const useTransactionHistoryWithoutStatuses = (address: Address | undefined) => { error: withdrawalsError, isLoading: withdrawalsLoading } = useSWRImmutable( - shouldFetch ? ['tx_list', 'withdrawals', address, isTestnetMode] : null, - () => fetcher('withdrawals') + shouldFetch + ? ['tx_list', 'withdrawals', address, isTestnetMode, fetchFor] + : null, + () => fetcher({ type: 'withdrawals', fetchFor }) ) const deposits = (depositsData || []).flat() - const withdrawals = (withdrawalsData || []).flat() // merge deposits and withdrawals and sort them by date - const transactions = [ - ...deposits, - ...withdrawals, - ...combinedCctpTransfers - ].flat() + const transactions = [...deposits, ...withdrawals].sort( + sortByTimestampDescending + ) return { - data: transactions, - loading: depositsLoading || withdrawalsLoading || cctpLoading, - error: depositsError ?? withdrawalsError, - failedChainPairs: failedChainPairs || [] + rawData: transactions, + rawDataLoading: depositsLoading || withdrawalsLoading, + rawDataError: depositsError ?? withdrawalsError, + rawDataErroredChains: erroredChains || [] } } -/** - * 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 useCctpTransactions = ({ address }: { address: Address | undefined }) => { const { chain } = useNetwork() + const [isTestnetMode] = useIsTestnetMode() const { isSmartContractWallet, isLoading: isLoadingAccountType } = useAccountType() - const { connector } = useAccount() + + // Check what type of CCTP (deposit, withdrawal or all) to fetch + // We need this because of Smart Contract Wallets + const cctpTypeToFetch = useCallback( + (chainPair: ChainPair): 'deposits' | 'withdrawals' | 'all' | undefined => { + if (isLoadingAccountType || !chain) { + return undefined + } + if (isSmartContractWallet) { + // fetch based on the connected network + if (chain.id === chainPair.parentChainId) { + return 'deposits' + } + if (chain.id === chainPair.childChainId) { + return 'withdrawals' + } + return undefined + } + // EOA + return isNetwork(chainPair.parentChainId).isTestnet === isTestnetMode + ? 'all' + : undefined + }, + [isSmartContractWallet, isLoadingAccountType, chain, isTestnetMode] + ) + + const cctpTransfersMainnet = useCctpFetching({ + walletAddress: address, + l1ChainId: ChainId.Ethereum, + l2ChainId: ChainId.ArbitrumOne, + pageNumber: 0, + pageSize: cctpTypeToFetch({ + parentChainId: ChainId.Ethereum, + childChainId: ChainId.ArbitrumOne + }) + ? 1000 + : 0, + type: + cctpTypeToFetch({ + parentChainId: ChainId.Ethereum, + childChainId: ChainId.ArbitrumOne + }) ?? 'all' + }) + + const cctpTransfersTestnet = useCctpFetching({ + walletAddress: address, + l1ChainId: ChainId.Sepolia, + l2ChainId: ChainId.ArbitrumSepolia, + pageNumber: 0, + pageSize: cctpTypeToFetch({ + parentChainId: ChainId.Sepolia, + childChainId: ChainId.ArbitrumSepolia + }) + ? 1000 + : 0, + type: + cctpTypeToFetch({ + parentChainId: ChainId.Sepolia, + childChainId: ChainId.ArbitrumSepolia + }) ?? 'all' + }) + + // TODO: Clean up this logic when introducing testnet/mainnet split + const combinedCctpTransfers = [ + ...(cctpTransfersMainnet.deposits?.completed || []), + ...(cctpTransfersMainnet.withdrawals?.completed || []), + ...(cctpTransfersTestnet.deposits?.completed || []), + ...(cctpTransfersTestnet.withdrawals?.completed || []), + ...(cctpTransfersMainnet.deposits?.pending || []), + ...(cctpTransfersMainnet.withdrawals?.pending || []), + ...(cctpTransfersTestnet.deposits?.pending || []), + ...(cctpTransfersTestnet.withdrawals?.pending || []) + ] + + const cctpLoading = + cctpTransfersMainnet.isLoadingDeposits || + cctpTransfersMainnet.isLoadingWithdrawals || + cctpTransfersTestnet.isLoadingDeposits || + cctpTransfersTestnet.isLoadingWithdrawals + + return { cctpTransactions: combinedCctpTransfers, cctpLoading } +} + +const useMappedSenderTransactionHistory = ({ + address, + runFetcher = false +}: { + address: Address | undefined + // TODO: refactor runFetcher, make the method run without it + // https://linear.app/offchain-labs/issue/FS-1063/remove-runfetcher-in-usetransactionhistory + runFetcher?: boolean +}): UseMappedTransactionHistoryResult & { + firstPageLoaded: boolean + pause: () => void + resume: () => void + addPendingTransaction: (tx: MergedTransaction) => void +} => { // 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 { chain } = useNetwork() + const [isTestnetMode] = useIsTestnetMode() + const { isSmartContractWallet, isLoading: isLoadingAccountType } = + useAccountType() + const { connector } = useAccount() + const [fetching, setFetching] = useState(true) const [pauseCount, setPauseCount] = useState(0) - const { - data, - loading: isLoadingTxsWithoutStatus, - error, - failedChainPairs - } = useTransactionHistoryWithoutStatuses(address) + const { rawData, rawDataLoading, rawDataError, rawDataErroredChains } = + useRawTransactionHistory({ + address, + fetchFor: 'sender', + shouldStartToFetch: true + }) + + function pause() { + setFetching(false) + } + + function resume() { + setFetching(true) + setPage(prevPage => prevPage + 1) + } - const getCacheKey = useCallback( + const getSwrCacheKey = useCallback( (pageNumber: number, prevPageTxs: MergedTransaction[]) => { if (prevPageTxs) { if (prevPageTxs.length === 0) { @@ -512,11 +604,16 @@ export const useTransactionHistory = ( } } - return address && !isLoadingTxsWithoutStatus && !isLoadingAccountType - ? (['complete_tx_list', address, pageNumber, data] as const) + return address && !rawDataLoading && !isLoadingAccountType + ? ([ + 'mapped_transaction_history_for_sender', + address, + pageNumber, + rawData + ] as const) : null }, - [address, isLoadingTxsWithoutStatus, data, isLoadingAccountType] + [address, rawDataLoading, isLoadingAccountType, rawData] ) const depositsFromCache = useMemo(() => { @@ -555,15 +652,15 @@ export const useTransactionHistory = ( ]) const { - data: txPages, - error: txPagesError, + data: senderTxPages, + error: senderTxPagesError, size: page, setSize: setPage, - mutate: mutateTxPages, - isValidating, - isLoading: isLoadingFirstPage + mutate: mutateSenderTxPages, + isValidating: isValidatingSenderTxPages, + isLoading: isLoadingSenderFirstPage } = useSWRInfinite( - getCacheKey, + getSwrCacheKey, ([, , _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 @@ -597,17 +694,37 @@ export const useTransactionHistory = ( } ) - // 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' + 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 (typeof rawDataError !== 'undefined') { + console.warn(rawDataError) + captureSentryErrorWithExtraData({ + error: rawDataError, + originFunction: 'useRawTransactionHistory' + }) + } - const completed = - !isLoadingFirstPage && - typeof txPages !== 'undefined' && - data.length === txPages.flat().length + if (typeof senderTxPagesError !== 'undefined') { + console.warn(senderTxPagesError) + captureSentryErrorWithExtraData({ + error: senderTxPagesError, + originFunction: 'useMappedSenderTransactionHistory' + }) + } + }, [rawDataError, senderTxPagesError]) // transfers initiated by the user during the current session // we store it separately as there are a lot of side effects when mutating SWRInfinite @@ -616,34 +733,21 @@ export const useTransactionHistory = ( 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() + const senderTransactionsWithNewTransactions: MergedTransaction[] = + useMemo(() => { + const txs = [ + ...(newTransactionsData || []), + ...(senderTxPages || []) + ].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]) + }, [newTransactionsData, senderTxPages, address]) - const addPendingTransaction = useCallback( - (tx: MergedTransaction) => { - if (!isTxPending(tx)) { - return - } - - mutateNewTransactionsData(currentNewTransactions => { - if (!currentNewTransactions) { - return [tx] - } - - return [tx, ...currentNewTransactions] - }) - }, - [mutateNewTransactionsData] - ) - - const updateCachedTransaction = useCallback( + const updateTransactionInSwrCache = useCallback( (newTx: MergedTransaction) => { // check if tx is a new transaction initiated by the user, and update it const foundInNewTransactions = @@ -663,7 +767,7 @@ export const useTransactionHistory = ( // tx not found in the new user initiated transaction list // look in the paginated historical data - mutateTxPages(prevTxPages => { + mutateSenderTxPages(prevTxPages => { if (!prevTxPages) { return } @@ -705,73 +809,66 @@ export const useTransactionHistory = ( return newTxPages }, false) }, - [mutateNewTransactionsData, mutateTxPages, newTransactionsData] + [mutateNewTransactionsData, mutateSenderTxPages, newTransactionsData] ) - const updatePendingTransaction = useCallback( - async (tx: MergedTransaction) => { + const addPendingTransaction = useCallback( + (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 (tx.isCctp) { - const updatedCctpTransfer = await getUpdatedCctpTransfer(tx) - updateCachedTransaction(updatedCctpTransfer) - return - } + mutateNewTransactionsData(currentNewTransactions => { + if (!currentNewTransactions) { + return [tx] + } - // ETH or token withdrawal - if (tx.isWithdrawal) { - const updatedWithdrawal = await getUpdatedWithdrawal(tx) - updateCachedTransaction(updatedWithdrawal) - return - } + return [tx, ...currentNewTransactions] + }) + }, + [mutateNewTransactionsData] + ) - const isDifferentDestinationAddress = isCustomDestinationAddressTx(tx) + const updatePendingTransaction = useCallback( + async (tx: MergedTransaction) => { + const foundInSwrCache = senderTransactionsWithNewTransactions.find( + t => tx.txId === t.txId && tx.childChainId === t.childChainId + ) - // ETH deposit to the same address - if (tx.assetType === AssetType.ETH && !isDifferentDestinationAddress) { - const updatedEthDeposit = await getUpdatedEthDeposit(tx) - updateCachedTransaction(updatedEthDeposit) + if (!foundInSwrCache) { return } - // Token deposit or ETH deposit to a different destination address - const updatedRetryableDeposit = await getUpdatedRetryableDeposit(tx) - updateCachedTransaction(updatedRetryableDeposit) + const updatedPendingTransaction = await getUpdatedPendingTransaction(tx) + updateTransactionInSwrCache(updatedPendingTransaction) }, - [updateCachedTransaction] + [senderTransactionsWithNewTransactions, updateTransactionInSwrCache] ) - 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]) + // based on an example from SWR + // https://swr.vercel.app/examples/infinite-loading + const isLoadingMore = + page > 0 && + typeof senderTxPages !== 'undefined' && + typeof senderTxPages[page - 1] === 'undefined' + + const senderCompleted = + !isLoadingSenderFirstPage && + typeof senderTxPages !== 'undefined' && + rawData.length === senderTxPages.flat().length useEffect(() => { - if (!txPages || !fetching || !runFetcher || isValidating) { + if ( + !senderTxPages || + !fetching || + !runFetcher || + isValidatingSenderTxPages + ) { return } - const firstPage = txPages[0] - const lastPage = txPages[txPages.length - 1] + const firstPage = senderTxPages[0] + const lastPage = senderTxPages[senderTxPages.length - 1] if (!firstPage || !lastPage) { return @@ -804,58 +901,231 @@ export const useTransactionHistory = ( } // make sure we don't over-fetch - if (page === txPages.length) { + if (page === senderTxPages.length) { setPage(prevPage => prevPage + 1) } - }, [txPages, setPage, page, pauseCount, fetching, runFetcher, isValidating]) + }, [ + senderTxPages, + setPage, + page, + pauseCount, + fetching, + runFetcher, + isValidatingSenderTxPages + ]) + + return { + transactions: senderTransactionsWithNewTransactions || [], + error: senderTxPagesError, + completed: senderCompleted, + loading: rawDataLoading || isLoadingSenderFirstPage || isLoadingMore, + firstPageLoaded: !isLoadingSenderFirstPage, + rawDataErroredChains, + pause, + resume, + addPendingTransaction, + updatePendingTransaction + } +} + +const useMappedReceiverTransactionHistory = ({ + address, + shouldStartToFetch +}: { + address: Address | undefined + shouldStartToFetch: boolean +}): UseMappedTransactionHistoryResult => { + const { isLoading: isLoadingAccountType } = useAccountType() + + const { rawData, rawDataLoading, rawDataErroredChains, rawDataError } = + useRawTransactionHistory({ + address, + fetchFor: 'receiver', + shouldStartToFetch + }) + + const getSwrCacheKey = useCallback(() => { + if (!shouldStartToFetch) { + return null + } + + return address && !rawDataLoading && !isLoadingAccountType + ? (['mapped_transaction_history_for_receiver', address, rawData] as const) + : null + }, [ + address, + shouldStartToFetch, + isLoadingAccountType, + rawData, + rawDataLoading + ]) + + const { + data: receiverTransactions, + error: receiverTransactionsError, + isLoading: isLoadingReceiverTransactions, + mutate: mutateReceiverTransactions + } = useSWRImmutable(getSwrCacheKey, async ([, _address, _data]) => { + const _receiverTransactions = _data.filter(tx => { + if (isTransferTeleportFromSubgraph(tx)) { + return tx.sender.toLowerCase() !== _address?.toLowerCase() + } + + if (isDeposit(tx)) { + return ( + tx.destination && + tx.sender.toLowerCase() !== tx.destination.toLowerCase() + ) + } + + if (isWithdrawalFromSubgraph(tx)) { + return tx.sender.toLowerCase() !== tx.receiver.toLowerCase() + } + + if (isTokenWithdrawal(tx)) { + return tx._from.toLowerCase() !== tx._to.toLowerCase() + } + + // Native token withdrawals always fetch by receiver. We need to fetch for the sender here too. + return true + }) + + const results = [] + for (const tx of _receiverTransactions) { + const transformedTx = await transformTransaction(tx) + results.push(transformedTx) + } + return results + }) + + const updateTransactionInSwrCache = useCallback( + (newTx: MergedTransaction) => { + mutateReceiverTransactions(prevReceiverTransactions => { + return prevReceiverTransactions?.map(tx => { + if ( + tx.childChainId === newTx.childChainId && + tx.txId === newTx.txId + ) { + return newTx + } + return tx + }) + }) + }, + [mutateReceiverTransactions] + ) + + const updatePendingTransaction = useCallback( + async (tx: MergedTransaction) => { + if (!receiverTransactions) { + return + } + + const foundInSwrCache = receiverTransactions + .flat() + .find(t => tx.txId === t.txId && tx.childChainId === t.childChainId) + + if (!foundInSwrCache) { + return + } + + const updatedPendingTransaction = await getUpdatedPendingTransaction(tx) + updateTransactionInSwrCache(updatedPendingTransaction) + }, + [receiverTransactions, updateTransactionInSwrCache] + ) useEffect(() => { - if (typeof error !== 'undefined') { - console.warn(error) + if (typeof rawDataError !== 'undefined') { + console.warn(rawDataError) captureSentryErrorWithExtraData({ - error, - originFunction: 'useTransactionHistoryWithoutStatuses' + error: rawDataError, + originFunction: 'useRawTransactionHistory' }) } - if (typeof txPagesError !== 'undefined') { - console.warn(txPagesError) + if (typeof receiverTransactionsError !== 'undefined') { + console.warn(receiverTransactionsError) captureSentryErrorWithExtraData({ - error: txPagesError, - originFunction: 'useTransactionHistory' + error: receiverTransactionsError, + originFunction: 'useMappedReceiverTransactionHistory' }) } - }, [error, txPagesError]) + }, [rawDataError, receiverTransactionsError]) - function pause() { - setFetching(false) + return { + transactions: receiverTransactions || [], + error: receiverTransactionsError, + completed: !isLoadingReceiverTransactions, + loading: isLoadingReceiverTransactions || rawDataLoading, + rawDataErroredChains, + updatePendingTransaction } +} - function resume() { - setFetching(true) - setPage(prevPage => prevPage + 1) - } +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 { + transactions: senderTransactions, + loading: loadingForSender, + completed: completedForSender, + error: errorForSender, + rawDataErroredChains: rawDataErroredChainsForSender, + firstPageLoaded: firstPageLoadedForSender, + pause, + resume, + addPendingTransaction, + updatePendingTransaction: updatePendingSenderTransaction + } = useMappedSenderTransactionHistory({ + address, + runFetcher + }) - if (isLoadingTxsWithoutStatus || error) { - return { - transactions: [], - loading: isLoadingTxsWithoutStatus, - error, - failedChainPairs: [], - completed: true, - pause, - resume, - addPendingTransaction, - updatePendingTransaction - } - } + const { + transactions: receiverTransactions, + loading: loadingForReceiver, + completed: completedForReceiver, + error: errorForReceiver, + rawDataErroredChains: rawDataErroredChainsForReceiver, + updatePendingTransaction: updatePendingReceiverTransaction + } = useMappedReceiverTransactionHistory({ + address, + shouldStartToFetch: firstPageLoadedForSender + }) + + const { cctpTransactions, cctpLoading } = useCctpTransactions({ address }) + + const updatePendingTransaction = useCallback( + async (tx: MergedTransaction) => { + await updatePendingSenderTransaction(tx) + await updatePendingReceiverTransaction(tx) + }, + [updatePendingReceiverTransaction, updatePendingSenderTransaction] + ) return { - transactions, - loading: isLoadingFirstPage || isLoadingMore, - completed, - error: txPagesError ?? error, - failedChainPairs, + transactions: [ + ...senderTransactions, + ...receiverTransactions, + ...cctpTransactions + ].sort(sortByTimestampDescending), + senderData: { + senderTransactions, + loadingForSender: loadingForSender || cctpLoading, + completedForSender, + errorForSender + }, + receiverData: { + receiverTransactions, + loadingForReceiver, + completedForReceiver, + errorForReceiver + }, + rawDataErroredChains: + rawDataErroredChainsForSender || rawDataErroredChainsForReceiver, pause, resume, addPendingTransaction, diff --git a/packages/arb-token-bridge-ui/synpress.config.ts b/packages/arb-token-bridge-ui/synpress.config.ts index 84a332c1b0..cac18178d0 100644 --- a/packages/arb-token-bridge-ui/synpress.config.ts +++ b/packages/arb-token-bridge-ui/synpress.config.ts @@ -45,7 +45,8 @@ const isOrbitTest = [ process.env.E2E_ORBIT, process.env.E2E_ORBIT_CUSTOM_GAS_TOKEN ].includes('true') -const shouldRecordVideo = process.env.CYPRESS_RECORD_VIDEO === 'true' +// const shouldRecordVideo = process.env.CYPRESS_RECORD_VIDEO === 'true' +const shouldRecordVideo = true const l3Network = process.env.ORBIT_CUSTOM_GAS_TOKEN === 'true' diff --git a/packages/arb-token-bridge-ui/tests/e2e/specfiles.json b/packages/arb-token-bridge-ui/tests/e2e/specfiles.json index fd907011e2..2f5e7a8b91 100644 --- a/packages/arb-token-bridge-ui/tests/e2e/specfiles.json +++ b/packages/arb-token-bridge-ui/tests/e2e/specfiles.json @@ -12,7 +12,7 @@ { "name": "Withdraw native token", "file": "tests/e2e/specs/**/withdrawNativeToken.cy.{js,jsx,ts,tsx}", - "recordVideo": "false" + "recordVideo": "true" }, { "name": "Deposit ERC20", diff --git a/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawERC20.cy.ts b/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawERC20.cy.ts index a1ac1fb75e..e02f47c0db 100644 --- a/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawERC20.cy.ts +++ b/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawERC20.cy.ts @@ -155,6 +155,8 @@ describe('Withdraw ERC20 Token', () => { cy.switchToTransactionHistoryTab('pending') + cy.wait(5_000) + cy.findClaimButton( formatAmount(ERC20AmountToSend, { symbol: testCase.symbol diff --git a/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawNativeToken.cy.ts b/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawNativeToken.cy.ts index 5de34a32de..1401ef2a42 100644 --- a/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawNativeToken.cy.ts +++ b/packages/arb-token-bridge-ui/tests/e2e/specs/withdrawNativeToken.cy.ts @@ -102,12 +102,14 @@ describe('Withdraw native token', () => { }) }) - it('should claim funds', { defaultCommandTimeout: 200_000 }, () => { + it('should claim funds', { defaultCommandTimeout: 300_000 }, () => { // increase the timeout for this test as claim button can take ~(20 blocks *10 blocks/sec) to activate cy.login({ networkType: 'parentChain' }) // login to L1 to claim the funds (otherwise would need to change network after clicking on claim) cy.switchToTransactionHistoryTab('pending') + cy.wait(5_000) + cy.findClaimButton( formatAmount(ETHToWithdraw, { symbol: nativeTokenSymbol