feat: sync batch status from confirmed transactions#2239
feat: sync batch status from confirmed transactions#2239DanielSinclair merged 1 commit intomasterfrom
Conversation
f0bd569 to
8e02de6
Compare
27ed5e7 to
598f8d1
Compare
0d7df2b to
f0b92c6
Compare
598f8d1 to
dfd871f
Compare
DanielSinclair
left a comment
There was a problem hiding this comment.
A few race conditions and edge cases described here. Issue 3 seems the most important:
Issue 1: Redundant RPC calls — receipts fetched twice per status query
When a tx confirms, the flow is:
tx confirms
→ updateBatchStatusFromReceipts() # fetches ALL receipts via RPC
→ writes status 200/500/600 to storedapp calls wallet_getCallsStatus
→ provider reads batch.status (200)
→ provider fetches ALL receipts via RPC AGAIN (lines 646-669)
→ returns to dappThe extension fetches receipts to derive the status, then the provider fetches the exact
same receipts to return them. Neither caches the receipts — they're thrown away both times.
Every wallet_getCallsStatus call for a terminal batch hits RPC for all tx hashes.Fix: Store the receipts (or at least status-per-hash) in the BatchRecord when
updateBatchStatusFromReceipts runs. The provider can then return them directly without
re-fetching.
Issue 2: Dual update paths race on the same batch
Both watchers fire updateBatchesForTx for the same tx hash:
Background: watchPendingTransactions.ts (line 173)
└─ updateBatchesForTx(hash, chainId) → fetches receipts → setBatch(...)Popup: useTransactionListForPendingTxs.ts (line 134)
└─ updateBatchesForTx(hash, chainId) → fetches receipts → setBatch(...)When both are active (popup is open), they run concurrently:
- Both call getProvider().getTransactionReceipt() for every tx hash — 2N RPC calls instead
of N- Both call setBatch() — the last write wins, which is fine since they should produce the
same result, but the work is entirely duplicated- If one fails silently (.catch(() => undefined)), the other still succeeds, so the
redundancy acts as inadvertent reliability — but that's not documented
Issue 3: No guard against re-processing terminal batches
// updateBatchStatusFromReceipts
export async function updateBatchStatusFromReceipts(batchKey: string): Promise {
const batch = useBatchStore.getState().batches[batchKey];
if (!batch) return;
// ← no check: if (batch.status !== BatchStatus.Pending) return;const provider = getProvider({ chainId: batch.chainId }); const statuses = await Promise.all( batch.txHashes.map(async (hash) => { ... }) ); // ... derives status, writes to store}
The pending tx watcher runs on a polling interval. Every poll cycle that sees a confirmed tx
will call updateBatchesForTx again. Since there's no early return for already-terminal
batches, it re-fetches all receipts and re-writes the same status on every cycle until the
tx is removed from the pending store.For a batch with 3 tx hashes on a 4-second poll cycle, that's 3 RPC calls every 4 seconds
for no reason.Fix: Add at line 48:
if (batch.status !== BatchStatus.Pending) return;
Issue 4: updateBatchesForTx return value lies
export async function updateBatchesForTx(txHash: string, chainId: number): Promise {
const batchKeys = getBatchKeysContainingTx(txHash, chainId);
await Promise.all(
batchKeys.map((key) =>
updateBatchStatusFromReceipts(key).catch(() => undefined), // ← swallows failures
),
);
return batchKeys.length; // ← returns matched count, not success count
}The callers log this as "synced":
logger.info(... ${batchCount} batch(es) synced);If 3 batches matched but 2 failed (RPC errors, etc.), it reports "3 batch(es) synced." The
.catch(() => undefined) intentionally swallows failures for resilience, but the return value
and log message are misleading.Fix: Either track actual successes:
const results = await Promise.all(
batchKeys.map((key) =>
updateBatchStatusFromReceipts(key).then(() => true).catch(() => false),
),
);
return results.filter(Boolean).length;
Or change the log to say "matched" instead of "synced."
Issue 5: Linear scan over all batches on every confirmed tx
export function getBatchKeysContainingTx(txHash: string, chainId: number): string[] {
const { batches } = useBatchStore.getState();
return Object.entries(batches)
.filter(([, batch]) =>
batch.chainId === chainId &&
batch.txHashes.includes(txHash as0x${string}),
)
.map(([key]) => key);
}This scans every batch record for every confirmed transaction. In practice the batch count
is small (tens), so this is fine. But combined with Issue 3 (no terminal guard), this scan
runs repeatedly for non-batch transactions that will never match, on every poll cycle.
Issue 6: receipt.status edge case
return receipt ? (receipt.status === 1 ? 'confirmed' : 'failed') : null;
In ethers v5, TransactionReceipt.status is number | undefined. Pre-Byzantium receipts have
status: undefined, which makes status === 1 false → classified as 'failed'. This is
consistent with the rest of the codebase and only matters for ancient blocks, but it's worth
noting.
Issue 7: Partial receipt availability causes silent stall
if (statuses.some((s) => s === null)) return;
If any receipt isn't available yet (RPC returns null), the function bails without updating.
This is correct — you don't want to derive a partial status. But there's no retry mechanism.
The function only runs when triggered by a confirmed tx. If the first attempt gets a null
receipt (node lag, different RPC endpoint), the batch stays Pending until the next polling
cycle happens to pick up the same tx again. If the tx gets removed from the pending store
before that retry, the batch stays Pending forever.This is the most subtle issue. The timeline:
t=0: tx confirmed, watcher fires updateBatchesForTx
t=0: receipt for hash[1] returns null (RPC lag)
t=0: statuses.some(null) → return; (no update)
t=1: removePendingTransactionsForAddress removes tx
t=∞: batch stuck at status 100 foreverThe popup watcher is particularly vulnerable since removePendingTransactionsForAddress runs
synchronously after the fire-and-forget updateBatchesForTx:// useTransactionListForPendingTxs.ts
newlyConfirmedTransactions.forEach((tx) => {
updateBatchesForTx(tx.hash, tx.chainId) // ← async, not awaited
.then(...)
.catch(() => undefined);
});removePendingTransactionsForAddress({ // ← runs immediately
address: currentAddress,
transactionsToRemove: newlyConfirmedTransactions.map(...)
});The tx is removed from the pending store before the batch update even starts its RPC calls.
The background watcher has the same issue — the pending tx removal happens elsewhere in the
same loop iteration.Fix: Either await the batch update before removing the pending tx, or add a separate
background job that periodically scans for Pending batches with non-empty txHashes and
retries receipt fetching.
src/core/state/transactions/pendingTransactions/watchPendingTransactions.ts
Show resolved
Hide resolved
f0b92c6 to
732e6f9
Compare
dfd871f to
0bb54c8
Compare
732e6f9 to
bc92150
Compare
502ae8f to
7dbfe82
Compare
bc92150 to
59e9783
Compare
7dbfe82 to
f8096cd
Compare
f8096cd to
cd79546
Compare
2d4b17c to
74d097a
Compare
cd79546 to
2a44679
Compare
db03b74 to
1009cbd
Compare
e945d1e to
806e379
Compare
| * - The nonce was used by a different tx (the original batch tx) | ||
| * - Mark as Confirmed since the batch tx won the race | ||
| */ | ||
| async function updateBatchStatusFromReceipt( |
There was a problem hiding this comment.
I think we still have an edge case with speed up replacement:
Scenario
- User submits batch tx with hash 0xAAA at nonce 42
- User speeds up → replacement 0xBBB is submitted at nonce 42
- 0xBBB mines, backend reports nonce 42 is confirmed
- Popup watcher sees txNonce (42) <= latestTxNonce → marks tx as confirmed
- Calls updateBatchesForMinedTx({ hash: 0xAAA, ... }) — the stale hash
- fetchReceipt(0xAAA) returns null (it never mined)
- Falls through to line 99: newStatus = BatchStatus.Confirmed with no receipt
The batch is now Confirmed based on a receipt lookup for a hash that never
mined. If the replacement was actually a cancel, isCancellation comes from
the original pending tx's typeOverride (which is not 'cancel'), so the
cancel is misclassified as a normal confirmation.
806e379 to
7a0d8c3
Compare
1009cbd to
d5481e9
Compare
7a0d8c3 to
6a9b542
Compare
d5481e9 to
5ca47c4
Compare
6a9b542 to
7c76508
Compare
766cdc6 to
2a2992a
Compare
7c76508 to
2cee23a
Compare
2a2992a to
19ce482
Compare
2cee23a to
0bb7ed9
Compare
19ce482 to
5662391
Compare
0bb7ed9 to
fc2056f
Compare
5662391 to
2db08fc
Compare
fc2056f to
51f9f18
Compare
2db08fc to
ca74d62
Compare
51f9f18 to
50c2276
Compare
ca74d62 to
3e432b2
Compare
fix: adopt viem type
3e432b2 to
b06465d
Compare
50c2276 to
863f350
Compare
Merge activity
|

Sync batch status from confirmed transactions
When a pending transaction confirms, we now check if its nonce belongs to any stored batch and update the batch's EIP-5792 status code. This closes the lifecycle loop: dapps polling
wallet_getCallsStatussee accurate Confirmed/Reverted status after the tx mines. Cancel and speedup races are handled explicitly.What changed
updateBatchStatus.ts– Core module with:MinedTxInfo– Structured type carrying nonce, chainId, sender, hash, and isCancellation.getBatchKeysForNonce– Finds batch store keys matching a nonce + chainId + sender (nonce is stable across speedup/cancel replacements).updateBatchStatusFromReceipt– Fetches the receipt for the resolved tx hash and derives status: Confirmed if succeeded, CompleteRevert if failed or cancelled. Stores the receipt as an EIP-5792CallReceipton the batch record.updateBatchesForMinedTx– Entry point: finds matching batches by nonce and updates each. Returns count of matched batches for logging.toCallReceipt/fetchReceipt– Helpers to convert ethers receipts to EIP-5792 format and safely fetch receipts.watchPendingTransactionsconstructs aMinedTxInfo(derivingisCancellationfromtypeOverride === 'cancel') and callsupdateBatchesForMinedTxafter a transaction is confirmed.useTransactionListForPendingTxsdoes the same for transactions confirmed via the Rainbow backend's consolidated endpoint.updateBatchStatus.test.tscovers nonce-based lookup, confirmation, revert, cancel-wins, cancel-loses, speedup-wins, speedup-loses, receipt storage, and multi-batch matching.Design choices & review focus
isCancellationflag drives status logic. A confirmed cancel → CompleteRevert. A cancel with no receipt means the original batch tx won the nonce race → Confirmed. Speedup follows the same receipt-based logic as the original tx.CallReceiptformat and stored on theBatchRecord. This avoids redundant RPC calls whenwallet_getCallsStatusis polled later — the provider can return stored receipts directly..catch(() => undefined)so batch status update failures don't block transaction processing. Batch status is nice-to-have; transaction lifecycle is critical.PR-Codex overview
This PR focuses on enhancing the handling of mined transactions in the
pendingTransactionsmodule. It introduces new types and functions to update batch statuses based on transaction confirmations or cancellations.Detailed summary
MinedTxInfotype to encapsulate mined transaction details.updateBatchesForMinedTxto update batch statuses based on mined transactions.getBatchKeysForNonceto retrieve relevant batch keys.watchPendingTransactionsto callupdateBatchesForMinedTx.useTransactionListForPendingTxsto handle consolidated transactions and sync batches.updateBatchStatus.test.ts.