Skip to content

Commit b99cbf9

Browse files
fix(bridge): clear stale LiFi pending transfers whose source tx never landed
1 parent 4bf26fc commit b99cbf9

1 file changed

Lines changed: 45 additions & 1 deletion

File tree

  • packages/arb-token-bridge-ui/src/components/TransactionHistory

packages/arb-token-bridge-ui/src/components/TransactionHistory/helpers.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,44 @@ export async function getUpdatedCctpTransfer(tx: MergedTransaction): Promise<Mer
566566
return { ...tx, status: WithdrawalStatus.UNCONFIRMED };
567567
}
568568

569+
// A LiFi entry is cached optimistically once the wallet returns a source tx hash. If that
570+
// transaction never actually lands on-chain (reverted, or dropped from the mempool and never
571+
// mined), the entry would otherwise sit "pending" forever — LiFi's status API keeps reporting
572+
// PENDING for a hash it never saw. We only give up after a grace window, so a tx that is merely
573+
// still propagating isn't reconciled away prematurely.
574+
const LIFI_SOURCE_TX_DROPPED_THRESHOLD_MS = 1000 * 60 * 30; // 30 minutes
575+
576+
/**
577+
* Corroborating on-chain check, used only when LiFi's status API has no record of the source tx
578+
* executing. Returns true only when the cached source tx definitively did not succeed: it
579+
* reverted, or (past the grace window) is neither mined nor still in the mempool. A transient RPC
580+
* error returns false, so a healthy transfer is never turned terminal on a single bad read.
581+
*/
582+
async function isLifiSourceTxDropped(tx: LifiMergedTransaction): Promise<boolean> {
583+
if (
584+
typeof tx.createdAt !== 'number' ||
585+
dayjs().valueOf() - tx.createdAt <= LIFI_SOURCE_TX_DROPPED_THRESHOLD_MS
586+
) {
587+
return false;
588+
}
589+
590+
try {
591+
const sourceChainProvider = getProviderForChainId(tx.sourceChainId);
592+
const sourceTxReceipt = await sourceChainProvider.getTransactionReceipt(tx.txId);
593+
594+
if (sourceTxReceipt) {
595+
// Mined: only counts as failed if it reverted on-chain.
596+
return sourceTxReceipt.status === 0;
597+
}
598+
599+
// No receipt: dropped only if the node no longer knows the tx (not mined, not in mempool).
600+
const pendingSourceTx = await sourceChainProvider.getTransaction(tx.txId);
601+
return !pendingSourceTx;
602+
} catch {
603+
return false;
604+
}
605+
}
606+
569607
export async function getUpdatedLifiTransfer(
570608
tx: LifiMergedTransaction,
571609
): Promise<MergedTransaction> {
@@ -610,7 +648,13 @@ export async function getUpdatedLifiTransfer(
610648
sourceStatus = WithdrawalStatus.CONFIRMED;
611649
destinationStatus = WithdrawalStatus.UNCONFIRMED;
612650
} else {
613-
sourceStatus = WithdrawalStatus.UNCONFIRMED;
651+
// LiFi has no record of the source tx executing. Past the grace window, corroborate against
652+
// the chain: if it reverted or was dropped (never mined, not in mempool), fail it instead of
653+
// leaving it pending forever. Both signals are required, so a transfer LiFi simply hasn't
654+
// indexed yet — or a transient RPC miss — won't be turned terminal.
655+
sourceStatus = (await isLifiSourceTxDropped(tx))
656+
? WithdrawalStatus.FAILURE
657+
: WithdrawalStatus.UNCONFIRMED;
614658
destinationStatus = WithdrawalStatus.UNCONFIRMED;
615659
}
616660
if ('txHash' in statusResponse.receiving) {

0 commit comments

Comments
 (0)