-
+
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,