From d54a3c6943fceb438369840f518e489bac34c2a5 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 8 May 2026 10:25:28 +0530 Subject: [PATCH 1/4] fix(bridge): make tx history resilient to per-tx transform failures --- .../TransactionHistoryTable.tsx | 70 ++++++++++++++----- .../src/hooks/useTransactionHistory.ts | 50 ++++++++++++- 2 files changed, 102 insertions(+), 18 deletions(-) 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..051382473 100644 --- a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx @@ -15,7 +15,11 @@ 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 { getNetworkName } from '../../util/networks'; @@ -80,25 +84,58 @@ export const HistoryLoader = () => { return Loading transactions...; }; -const FailedChainPairsTooltip = ({ failedChainPairs }: { failedChainPairs: ChainPair[] }) => { - if (failedChainPairs.length === 0) { +const truncateTxId = (txId: string) => { + if (txId.length <= 14) return txId; + return `${txId.slice(0, 8)}…${txId.slice(-6)}`; +}; + +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 ( +
  • + {truncateTxId(tx.txId)} on{' '} + {chainName} +
  • + ); + })} +
+
+ )}
} > @@ -119,6 +156,7 @@ export const TransactionHistoryTable = (props: TransactionHistoryTableProps) => completed, error, failedChainPairs, + failedTxs, resume, selectedTabIndex, oldestTxTimeAgoString, @@ -191,13 +229,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/useTransactionHistory.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts index ae4470e8e..f83329f68 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,41 @@ 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; + newlyFailed.push({ + txId: getTxIdFromTransaction(tx) ?? 'unknown', + parentChainId: ('parentChainId' in tx ? tx.parentChainId : undefined) as + | ChainId + | undefined, + childChainId: ('childChainId' in tx ? tx.childChainId : undefined) as ChainId | undefined, + }); + }); + + 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 +983,7 @@ export const useTransactionHistory = ( loading: isLoadingTxsWithoutStatus, error, failedChainPairs: [], + failedTxs: failedTxs ?? [], completed: true, pause, resume, @@ -953,6 +998,7 @@ export const useTransactionHistory = ( completed, error: txPagesError ?? error, failedChainPairs, + failedTxs: failedTxs ?? [], pause, resume, addPendingTransaction, From b3b31467c57edea9f3ece05064c973f2b8a4ad8e Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 8 May 2026 13:58:19 +0530 Subject: [PATCH 2/4] dev: use shortenaddress --- .../TransactionHistory/TransactionHistoryTable.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 051382473..989cb9e0a 100644 --- a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx @@ -22,6 +22,7 @@ import { } 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'; @@ -84,11 +85,6 @@ export const HistoryLoader = () => { return Loading transactions...; }; -const truncateTxId = (txId: string) => { - if (txId.length <= 14) return txId; - return `${txId.slice(0, 8)}…${txId.slice(-6)}`; -}; - const FailedFetchTooltip = ({ failedChainPairs, failedTxs, @@ -128,7 +124,7 @@ const FailedFetchTooltip = ({ : 'unknown chain'; return (
  • - {truncateTxId(tx.txId)} on{' '} + {shortenTxHash(tx.txId)} on{' '} {chainName}
  • ); From b120e3be6336b8e615c466674bad13c4a01658c1 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 8 May 2026 14:19:13 +0530 Subject: [PATCH 3/4] fix(bridge): report rejected tx transformations to Sentry --- .../src/hooks/useTransactionHistory.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts index f83329f68..d3d206e48 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts @@ -718,13 +718,27 @@ export const useTransactionHistory = ( } const tx = batch[i]; if (!tx) return; - newlyFailed.push({ - txId: getTxIdFromTransaction(tx) ?? 'unknown', - parentChainId: ('parentChainId' in tx ? tx.parentChainId : undefined) as - | ChainId - | undefined, - childChainId: ('childChainId' in tx ? tx.childChainId : undefined) as ChainId | undefined, + 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) { From 9ba30fa6fbbf75b9db9e6d4eeea8ab598f7662d2 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 8 May 2026 14:53:17 +0530 Subject: [PATCH 4/4] dev: add tests --- .../__tests__/useTransactionHistory.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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); + }); });