Skip to content

feat: sync batch status from confirmed transactions#2239

Merged
DanielSinclair merged 1 commit intomasterfrom
03-06-feat_sync_batch_status
Mar 20, 2026
Merged

feat: sync batch status from confirmed transactions#2239
DanielSinclair merged 1 commit intomasterfrom
03-06-feat_sync_batch_status

Conversation

@janek26
Copy link
Contributor

@janek26 janek26 commented Mar 16, 2026

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_getCallsStatus see 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-5792 CallReceipt on 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.
  • Background watcherwatchPendingTransactions constructs a MinedTxInfo (deriving isCancellation from typeOverride === 'cancel') and calls updateBatchesForMinedTx after a transaction is confirmed.
  • Popup watcheruseTransactionListForPendingTxs does the same for transactions confirmed via the Rainbow backend's consolidated endpoint.
  • TestsupdateBatchStatus.test.ts covers nonce-based lookup, confirmation, revert, cancel-wins, cancel-loses, speedup-wins, speedup-loses, receipt storage, and multi-batch matching.

Design choices & review focus

  • Nonce-based matching – Batches are looked up by nonce rather than tx hash. This is stable across speedup and cancel replacements: the nonce never changes, so the lookup works regardless of which replacement tx ends up mining.
  • Cancel/speedup awareness – The isCancellation flag 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.
  • Receipt caching – Receipts are converted to EIP-5792 CallReceipt format and stored on the BatchRecord. This avoids redundant RPC calls when wallet_getCallsStatus is polled later — the provider can return stored receipts directly.
  • Two update paths – The background service worker and the popup both independently watch for confirmations. This is intentional: the background catches confirmations when the popup is closed, and the popup catches confirmations reported by the backend indexer (which may arrive before or after the receipt-based check).
  • Fire-and-forget – Both callers use .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 pendingTransactions module. It introduces new types and functions to update batch statuses based on transaction confirmations or cancellations.

Detailed summary

  • Added MinedTxInfo type to encapsulate mined transaction details.
  • Implemented updateBatchesForMinedTx to update batch statuses based on mined transactions.
  • Introduced getBatchKeysForNonce to retrieve relevant batch keys.
  • Enhanced watchPendingTransactions to call updateBatchesForMinedTx.
  • Updated useTransactionListForPendingTxs to handle consolidated transactions and sync batches.
  • Added tests for batch status updates in updateBatchStatus.test.ts.

✨ Ask PR-Codex anything about this PR by commenting with /codex {your question}

@janek26 janek26 marked this pull request as ready for review March 16, 2026 12:38
@janek26 janek26 requested a review from DanielSinclair March 16, 2026 12:39
@janek26 janek26 force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch from f0bd569 to 8e02de6 Compare March 16, 2026 12:42
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch 2 times, most recently from 27ed5e7 to 598f8d1 Compare March 17, 2026 10:42
@janek26 janek26 force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch 2 times, most recently from 0d7df2b to f0b92c6 Compare March 17, 2026 13:21
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from 598f8d1 to dfd871f Compare March 17, 2026 13:21
Copy link
Collaborator

@DanielSinclair DanielSinclair left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 store

dapp calls wallet_getCallsStatus
→ provider reads batch.status (200)
→ provider fetches ALL receipts via RPC AGAIN (lines 646-669)
→ returns to dapp

The 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 as 0x${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 forever

The 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.

@janek26 janek26 force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch from f0b92c6 to 732e6f9 Compare March 18, 2026 09:02
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from dfd871f to 0bb54c8 Compare March 18, 2026 09:02
@janek26 janek26 force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch from 732e6f9 to bc92150 Compare March 18, 2026 12:42
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch 2 times, most recently from 502ae8f to 7dbfe82 Compare March 18, 2026 13:00
@janek26 janek26 force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch from bc92150 to 59e9783 Compare March 18, 2026 13:00
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from 7dbfe82 to f8096cd Compare March 18, 2026 13:25
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from f8096cd to cd79546 Compare March 19, 2026 12:38
@janek26 janek26 force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch 2 times, most recently from 2d4b17c to 74d097a Compare March 19, 2026 12:59
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from cd79546 to 2a44679 Compare March 19, 2026 12:59
@DanielSinclair DanielSinclair force-pushed the 03-06-feat_sync_batch_status branch from db03b74 to 1009cbd Compare March 20, 2026 07:43
@DanielSinclair DanielSinclair force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch from e945d1e to 806e379 Compare March 20, 2026 07:43
* - 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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we still have an edge case with speed up replacement:

Scenario

  1. User submits batch tx with hash 0xAAA at nonce 42
  2. User speeds up → replacement 0xBBB is submitted at nonce 42
  3. 0xBBB mines, backend reports nonce 42 is confirmed
  4. Popup watcher sees txNonce (42) <= latestTxNonce → marks tx as confirmed
  5. Calls updateBatchesForMinedTx({ hash: 0xAAA, ... }) — the stale hash
  6. fetchReceipt(0xAAA) returns null (it never mined)
  7. 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.

@janek26 janek26 force-pushed the 03-06-feat_multi_tx_simulation_and_batch_ui branch from 806e379 to 7a0d8c3 Compare March 20, 2026 10:42
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from 1009cbd to d5481e9 Compare March 20, 2026 10:42
@janek26 janek26 changed the base branch from 03-06-feat_multi_tx_simulation_and_batch_ui to graphite-base/2239 March 20, 2026 11:45
@janek26 janek26 force-pushed the graphite-base/2239 branch from 7a0d8c3 to 6a9b542 Compare March 20, 2026 11:45
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from d5481e9 to 5ca47c4 Compare March 20, 2026 11:45
@janek26 janek26 changed the base branch from graphite-base/2239 to feat/wallet-sendcalls-envelope-orpc-approval March 20, 2026 11:45
@janek26 janek26 force-pushed the feat/wallet-sendcalls-envelope-orpc-approval branch from 6a9b542 to 7c76508 Compare March 20, 2026 12:13
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch 2 times, most recently from 766cdc6 to 2a2992a Compare March 20, 2026 15:07
@janek26 janek26 force-pushed the feat/wallet-sendcalls-envelope-orpc-approval branch from 7c76508 to 2cee23a Compare March 20, 2026 15:07
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from 2a2992a to 19ce482 Compare March 20, 2026 15:57
@janek26 janek26 force-pushed the feat/wallet-sendcalls-envelope-orpc-approval branch from 2cee23a to 0bb7ed9 Compare March 20, 2026 16:42
@janek26 janek26 force-pushed the 03-06-feat_sync_batch_status branch from 19ce482 to 5662391 Compare March 20, 2026 16:42
@DanielSinclair DanielSinclair force-pushed the feat/wallet-sendcalls-envelope-orpc-approval branch from 0bb7ed9 to fc2056f Compare March 20, 2026 20:19
@DanielSinclair DanielSinclair force-pushed the 03-06-feat_sync_batch_status branch from 5662391 to 2db08fc Compare March 20, 2026 20:20
@DanielSinclair DanielSinclair force-pushed the feat/wallet-sendcalls-envelope-orpc-approval branch from fc2056f to 51f9f18 Compare March 20, 2026 22:37
@DanielSinclair DanielSinclair force-pushed the 03-06-feat_sync_batch_status branch from 2db08fc to ca74d62 Compare March 20, 2026 22:38
@DanielSinclair DanielSinclair force-pushed the feat/wallet-sendcalls-envelope-orpc-approval branch from 51f9f18 to 50c2276 Compare March 20, 2026 22:52
@DanielSinclair DanielSinclair force-pushed the 03-06-feat_sync_batch_status branch from ca74d62 to 3e432b2 Compare March 20, 2026 22:52
@DanielSinclair DanielSinclair changed the base branch from feat/wallet-sendcalls-envelope-orpc-approval to graphite-base/2239 March 20, 2026 23:44
@DanielSinclair DanielSinclair force-pushed the 03-06-feat_sync_batch_status branch from 3e432b2 to b06465d Compare March 20, 2026 23:54
@DanielSinclair DanielSinclair changed the base branch from graphite-base/2239 to master March 20, 2026 23:54
Copy link
Collaborator

DanielSinclair commented Mar 20, 2026

Merge activity

  • Mar 20, 11:59 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Mar 20, 11:59 PM UTC: @DanielSinclair merged this pull request with Graphite.

@DanielSinclair DanielSinclair merged commit 1b15d78 into master Mar 20, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants