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 3f04a47f5..989cb9e0a 100644 --- a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx @@ -15,9 +15,14 @@ import { Tooltip } from '@/app-components/Tooltip'; import { getProviderForChainId } from '@/token-bridge-sdk/utils'; import { useNativeCurrency } from '../../hooks/useNativeCurrency'; -import { ChainPair, UseTransactionHistoryResult } from '../../hooks/useTransactionHistory'; +import { + ChainPair, + FailedTx, + UseTransactionHistoryResult, +} from '../../hooks/useTransactionHistory'; import { MergedTransaction } from '../../state/app/state'; import { isTokenDeposit } from '../../state/app/utils'; +import { shortenTxHash } from '../../util/CommonUtils'; import { getNetworkName } from '../../util/networks'; import { EmptyTransactionHistory } from './EmptyTransactionHistory'; import { PendingDepositWarning } from './PendingDepositWarning'; @@ -80,25 +85,53 @@ export const HistoryLoader = () => { return Loading transactions...; }; -const FailedChainPairsTooltip = ({ failedChainPairs }: { failedChainPairs: ChainPair[] }) => { - if (failedChainPairs.length === 0) { +const FailedFetchTooltip = ({ + failedChainPairs, + failedTxs, +}: { + failedChainPairs: ChainPair[]; + failedTxs: FailedTx[]; +}) => { + if (failedChainPairs.length === 0 && failedTxs.length === 0) { return null; } return ( - We were unable to fetch data for the following chain pairs: - +
+ {failedChainPairs.length > 0 && ( +
+ We were unable to fetch data for the following chain pairs: +
    + {failedChainPairs.map((pair) => ( +
  • + {getNetworkName(pair.parentChainId)} + {' <> '} + {getNetworkName(pair.childChainId)} +
  • + ))} +
+
+ )} + {failedTxs.length > 0 && ( +
+ Failed to load the following transactions: +
    + {failedTxs.map((tx) => { + const chainName = tx.childChainId + ? getNetworkName(tx.childChainId) + : 'unknown chain'; + return ( +
  • + {shortenTxHash(tx.txId)} on{' '} + {chainName} +
  • + ); + })} +
+
+ )}
} > @@ -119,6 +152,7 @@ export const TransactionHistoryTable = (props: TransactionHistoryTableProps) => completed, error, failedChainPairs, + failedTxs, resume, selectedTabIndex, oldestTxTimeAgoString, @@ -191,13 +225,13 @@ export const TransactionHistoryTable = (props: TransactionHistoryTableProps) => > {loading ? (
- +
) : (
- + Showing {transactions.length} {isPendingTab ? 'pending' : 'settled'} transactions made in {oldestTxTimeAgoString}. diff --git a/packages/arb-token-bridge-ui/src/hooks/__tests__/useTransactionHistory.test.ts b/packages/arb-token-bridge-ui/src/hooks/__tests__/useTransactionHistory.test.ts index c0251e89a..cfcdd6f15 100644 --- a/packages/arb-token-bridge-ui/src/hooks/__tests__/useTransactionHistory.test.ts +++ b/packages/arb-token-bridge-ui/src/hooks/__tests__/useTransactionHistory.test.ts @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { Address } from 'viem'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as depositsHelpers from '../../util/deposits/helpers'; import { useArbQueryParams } from '../useArbQueryParams'; import { useTransactionHistory } from '../useTransactionHistory'; @@ -137,4 +138,46 @@ describe.sequential('useTransactionHistory', () => { expect(result.current.completed).toBe(true); }, ); + + it('tracks failures and keeps the rest of history rendering when a transformation rejects', async () => { + const mockUseArbQueryParams = vi.mocked(useArbQueryParams); + const [currentParams, setParams] = mockUseArbQueryParams(); + + mockUseArbQueryParams.mockReturnValue([ + { + ...currentParams, + sourceChain: 11155111, + disabledFeatures: [], + }, + setParams, + ]); + + // Inject a single rejection in the deposit transform path. If the wallet's + // first source tx is a deposit, this fires once and exercises the failure + // path; otherwise it doesn't fire and the test still validates no regression. + vi.spyOn(depositsHelpers, 'updateAdditionalDepositData').mockRejectedValueOnce( + new Error('test injected transform failure'), + ); + + const { result } = await renderHookAsyncUseTransactionHistory(wallets.WALLET_MULTIPLE_TX); + + // drive through both pages, mirroring the parametrized test flow + for (let page = 0; page < 2; page++) { + if (page > 0) { + act(() => { + result.current.resume(); + }); + } + // eslint-disable-next-line no-await-in-loop + await waitFor( + () => { + expect(result.current.loading).toBe(false); + }, + { timeout: 30_000, interval: 500 }, + ); + } + + // succeeded + failed should equal the source total (5 for this wallet) + expect(result.current.transactions.length + result.current.failedTxs.length).toBe(5); + }); }); diff --git a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts index ae4470e8e..d3d206e48 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts @@ -74,6 +74,7 @@ export type UseTransactionHistoryResult = { completed: boolean; error: unknown; failedChainPairs: ChainPair[]; + failedTxs: FailedTx[]; pause: () => void; resume: () => void; addPendingTransaction: (tx: MergedTransaction) => void; @@ -82,6 +83,12 @@ export type UseTransactionHistoryResult = { export type ChainPair = { parentChainId: ChainId; childChainId: ChainId }; +export type FailedTx = { + txId: string; + parentChainId: ChainId | undefined; + childChainId: ChainId | undefined; +}; + export type Deposit = Transaction; export type Withdrawal = WithdrawalFromSubgraph | WithdrawalInitiated | EthWithdrawal; @@ -601,6 +608,10 @@ export const useTransactionHistory = ( failedChainPairs, } = useTransactionHistoryWithoutStatuses(address); + const { data: failedTxs, mutate: mutateFailedTxs } = useSWRImmutable( + address ? ['failed_txs', address] : null, + ); + const getCacheKey = useCallback( (pageNumber: number, prevPageTxs: MergedTransaction[]) => { if (prevPageTxs) { @@ -678,7 +689,7 @@ export const useTransactionHistory = ( isLoading: isLoadingFirstPage, } = useSWRInfinite( getCacheKey, - ([, , _page, _data]) => { + async ([, , _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.concat(depositsFromCache); @@ -692,8 +703,55 @@ export const useTransactionHistory = ( const startIndex = _page * MAX_BATCH_SIZE; const endIndex = startIndex + MAX_BATCH_SIZE; + const batch = dedupedTransactions.slice(startIndex, endIndex); + + // allSettled so a single bad tx doesn't tank the whole page + const settled = await Promise.allSettled(batch.map(transformTransaction)); + + const succeeded: MergedTransaction[] = []; + const newlyFailed: FailedTx[] = []; + + settled.forEach((result, i) => { + if (result.status === 'fulfilled') { + succeeded.push(result.value); + return; + } + const tx = batch[i]; + if (!tx) return; + const txId = getTxIdFromTransaction(tx) ?? 'unknown'; + const parentChainId = ('parentChainId' in tx ? tx.parentChainId : undefined) as + | ChainId + | undefined; + const childChainId = ('childChainId' in tx ? tx.childChainId : undefined) as + | ChainId + | undefined; + + // allSettled swallows rejections; report each one to Sentry so per-tx + // failures remain observable. + captureSentryErrorWithExtraData({ + error: result.reason, + originFunction: 'useTransactionHistory.transformTransaction', + additionalData: { + txId, + parentChainId: String(parentChainId ?? 'unknown'), + childChainId: String(childChainId ?? 'unknown'), + }, + }); + + newlyFailed.push({ txId, parentChainId, childChainId }); + }); + + if (newlyFailed.length > 0) { + mutateFailedTxs((prev) => { + const merged = [...(prev ?? [])]; + for (const f of newlyFailed) { + if (!merged.some((m) => m.txId === f.txId)) merged.push(f); + } + return merged; + }, false); + } - return Promise.all(dedupedTransactions.slice(startIndex, endIndex).map(transformTransaction)); + return succeeded; }, { revalidateOnFocus: false, @@ -939,6 +997,7 @@ export const useTransactionHistory = ( loading: isLoadingTxsWithoutStatus, error, failedChainPairs: [], + failedTxs: failedTxs ?? [], completed: true, pause, resume, @@ -953,6 +1012,7 @@ export const useTransactionHistory = ( completed, error: txPagesError ?? error, failedChainPairs, + failedTxs: failedTxs ?? [], pause, resume, addPendingTransaction,