fix: reconcile pending bundles on a timer when blocks stall (SUP-1552) - #50
Open
jayeshbhole wants to merge 1 commit into
Open
fix: reconcile pending bundles on a timer when blocks stall (SUP-1552)#50jayeshbhole wants to merge 1 commit into
jayeshbhole wants to merge 1 commit into
Conversation
handleBlock is the only path that resolves a submitted bundle and returns its executor wallet to the sender pool, but watchBlocks only fires it on a new block. On a chain that produces blocks only when it receives a transaction, a bundler that stops submitting waits for a block that only it would have caused: no wallet is freed, every later bundle blocks forever in getWallet(), and userOps are accepted but never included. Keep the block-driven receipt polling (a receipt cannot change without a new block) and add a watchdog that runs handleBlock once no reconcile has happened within resubmitStuckTimeout while bundles are still pending. No RPC work on a healthy chain. Also hold currentlyHandlingBlock in try/finally: a throw in handleBlockInner left it set, wedging every later tick at the overlap guard.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of SUP-1552. Closes ZER-928
Problem
Chain 55516 (Geo testnet) stalled for 19h45m on 2026-08-13/14.
eth_sendUserOperationkept returning 200 OK; no userOp ever got a receipt. The same pattern has recurred since June: 18d, 7.5d, 7d, 5.6d, 4.3d, plus a dozen 20–50h gaps.It is a deadlock, and the bundler is both halves of it.
handleBlockis the only path that resolves a submitted bundle: it reads receipts, appliesresubmitStuckTimeout, rotates stuck bundles, and returns the executor wallet to the sender pool. Its only trigger iswatchBlocks({ onBlock }), which fires on a new block.Arbitrum Orbit chains produce a block only when they receive a transaction. On a low-traffic testnet the bundler is effectively the only writer, so:
getWallet()is an unbounded spin (createRedisSenderManager.ts:80), so every later bundle parks there forever.resubmitStuckTimeoutis a time condition evaluated only on a block event, so the escape hatch is unreachable exactly when it is needed.Nothing inside the loop can break it. What actually broke it was the customer sending an unrelated transaction from outside; the relayer resumed one second after that block landed.
Evidence (BetterStack,
Ultra Relay (Prod), chainId 55516)eth_sendUserOperation(inbound)eth_getBlockByNumber(watcher poll)eth_sendRawTransaction(outbound)mempool-storelogged userOps goingoutstanding -> processingthroughout the stall, then nothing: they reachedsendBundleToExecutorand parked ingetWallet(). Zero warns or errors for 20h, which is the tell thathandleBlocknever executed once. The trigger was upstream rate limiting oneth_sendRawTransactionat 23:16–23:29 ({"code":-32017,"message":"Rate Limit Exceeded..."}).Change
1. Stale-block watchdog.
watchBlocksstays: receipt polling is correctly block-driven, since a receipt cannot change without a new block and each tick costs one receipt lookup per pending bundle plus gas price reads. Replacing it with an interval would have raised RPC load on the very endpoint that was rate-limiting us.Instead, a timer re-arms only the time-based check that the block gate makes unreachable. It calls
handleBlockwhen no reconcile has happened withinresubmitStuckTimeoutand bundles are still pending. On a healthy chain a block arrives everyblockTime, solastReconcileAtstays fresh and the watchdog does zero RPC. It fires astaleBlockWatchdogFiredwarn when it does act.This is the same fix
reconcileQuarantinedWalletsalready applies one layer down, for the same reason: that code path's comment notes that waiting onlatest > stuckNoncewould deadlock, because the benched wallet sends nothing. Same bug, one level up. No new config option;resubmitStuckTimeoutandblockTimealready exist.2.
currentlyHandlingBlockheld intry/finally. It was set at the top ofhandleBlockInnerand cleared only on the success path. A throw in there (a store error infreeSubmittedBundle, say) left it set, so every later tick returned early at the overlap guard and reconciliation never resumed for the life of the process. That would also have silently defeated the watchdog above, so it is fixed here rather than separately.Tests
src/executor/executorManager.test.ts, 4 cases with fake timers. Each was confirmed to fail with the corresponding fix reverted:try/finallytry/finallypnpm lint: 0 errors (the 902 warnings are pre-existing onmain).Not in this PR
-32017was still firing on Aug 25 (02:36, 02:44, 03:21, 06:45, 08:01). This PR stops it from causing a multi-day outage; it does not stop the sends from failing. Needs an API key on the deployment.getWallet()timeout. The unbounded spin atcreateRedisSenderManager.ts:80is a landmine regardless. Worth bounding as defense-in-depth, but it treats starvation rather than the cause, so it is left out here.