Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,25 +85,53 @@ export const HistoryLoader = () => {
return <span className="animate-pulse">Loading transactions...</span>;
};

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 (
<Tooltip
content={
<div className="flex flex-col space-y-1 text-xs">
<span>We were unable to fetch data for the following chain pairs:</span>
<ul className="flex list-disc flex-col pl-4">
{failedChainPairs.map((pair) => (
<li key={`${pair.parentChainId}-${pair.childChainId}`}>
<b>{getNetworkName(pair.parentChainId)}</b>
{' <> '}
<b>{getNetworkName(pair.childChainId)}</b>
</li>
))}
</ul>
<div className="flex flex-col space-y-2 text-xs">
{failedChainPairs.length > 0 && (
<div className="flex flex-col space-y-1">
<span>We were unable to fetch data for the following chain pairs:</span>
<ul className="flex list-disc flex-col pl-4">
{failedChainPairs.map((pair) => (
<li key={`${pair.parentChainId}-${pair.childChainId}`}>
<b>{getNetworkName(pair.parentChainId)}</b>
{' <> '}
<b>{getNetworkName(pair.childChainId)}</b>
</li>
))}
</ul>
</div>
)}
{failedTxs.length > 0 && (
<div className="flex flex-col space-y-1">
<span>Failed to load the following transactions:</span>
<ul className="flex list-disc flex-col pl-4">
{failedTxs.map((tx) => {
const chainName = tx.childChainId
? getNetworkName(tx.childChainId)
: 'unknown chain';
return (
<li key={tx.txId}>
<span className="font-mono">{shortenTxHash(tx.txId)}</span> on{' '}
<b>{chainName}</b>
</li>
);
})}
</ul>
</div>
)}
</div>
}
>
Expand All @@ -119,6 +152,7 @@ export const TransactionHistoryTable = (props: TransactionHistoryTableProps) =>
completed,
error,
failedChainPairs,
failedTxs,
resume,
selectedTabIndex,
oldestTxTimeAgoString,
Expand Down Expand Up @@ -191,13 +225,13 @@ export const TransactionHistoryTable = (props: TransactionHistoryTableProps) =>
>
{loading ? (
<div className="flex h-[28px] items-center space-x-2">
<FailedChainPairsTooltip failedChainPairs={failedChainPairs} />
<FailedFetchTooltip failedChainPairs={failedChainPairs} failedTxs={failedTxs} />
<HistoryLoader />
</div>
) : (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-start space-x-1">
<FailedChainPairsTooltip failedChainPairs={failedChainPairs} />
<FailedFetchTooltip failedChainPairs={failedChainPairs} failedTxs={failedTxs} />
<span className="text-xs">
Showing {transactions.length} {isPendingTab ? 'pending' : 'settled'} transactions
made in {oldestTxTimeAgoString}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
});
});
64 changes: 62 additions & 2 deletions packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type UseTransactionHistoryResult = {
completed: boolean;
error: unknown;
failedChainPairs: ChainPair[];
failedTxs: FailedTx[];
pause: () => void;
resume: () => void;
addPendingTransaction: (tx: MergedTransaction) => void;
Expand All @@ -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;
Expand Down Expand Up @@ -601,6 +608,10 @@ export const useTransactionHistory = (
failedChainPairs,
} = useTransactionHistoryWithoutStatuses(address);

const { data: failedTxs, mutate: mutateFailedTxs } = useSWRImmutable<FailedTx[]>(
address ? ['failed_txs', address] : null,
);

const getCacheKey = useCallback(
(pageNumber: number, prevPageTxs: MergedTransaction[]) => {
if (prevPageTxs) {
Expand Down Expand Up @@ -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);
Expand All @@ -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[] = [];

Comment thread
dewanshparashar marked this conversation as resolved.
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;
Comment on lines +750 to +754
},
{
revalidateOnFocus: false,
Expand Down Expand Up @@ -939,6 +997,7 @@ export const useTransactionHistory = (
loading: isLoadingTxsWithoutStatus,
error,
failedChainPairs: [],
failedTxs: failedTxs ?? [],
completed: true,
pause,
resume,
Expand All @@ -953,6 +1012,7 @@ export const useTransactionHistory = (
completed,
error: txPagesError ?? error,
failedChainPairs,
failedTxs: failedTxs ?? [],
pause,
resume,
addPendingTransaction,
Expand Down
Loading