Skip to content

fix(bot-kit): stamp pending txs with the broadcast-time head - #116

Draft
garethfuller wants to merge 5 commits into
mainfrom
devin/1785486045-fix-stale-submitted-at-block
Draft

fix(bot-kit): stamp pending txs with the broadcast-time head#116
garethfuller wants to merge 5 commits into
mainfrom
devin/1785486045-fix-stale-submitted-at-block

Conversation

@garethfuller

@garethfuller garethfuller commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes CRTR-2851.

Production midnight-liquidation logged both of its last two liquidations as tx.dropped / reason: nonce_consumed even though both mined successfully on Base (0xdbaeba2c…d609dd2 nonce 238 block 49202078 status 1, 0x30207144…cf166e72 nonce 239 block 49228559 status 1). Two defects in the shared pending queue compound into that false failure.

A. submittedAtBlock was stamped with the tick-start head rather than the broadcast-time head.
tick.ts captures chainHead before discovery and passes it all the way through to queue.submit. On prod the probe/quote path is cold (candidates are rare), so plan.builttx.sent took ~11s ≈ 6 Base blocks. The entry was therefore born ~6 blocks stale and the very next onBlock satisfied blockNumber - entry.submittedAtBlock > stuckBlocks (4), fee-bumping 386ms after broadcast — before the original could possibly mine. Staging never reproduced it because constant candidates keep the probes warm and ticks finish in ~1.2s.

The queue now samples the head itself, after the send resolves:

const sampleSubmittedAtBlock = async (label, fallbackBlock) => {
  const observed = await tryCatch(getBlockNumber())
  if (observed.error) {
    logger.warn('head.read_failed', { label, reason: revertReason(observed.error) })
    return fallbackBlock
  }
  return observed.data > fallbackBlock ? observed.data : fallbackBlock
}

Sampling after the send rather than before it matters: send runs prepareTransactionRequest (gas estimation) before sendTransaction, so a pre-send sample can itself go stale by more than stuckBlocks on a slow estimation path. A slightly late sample is the safe direction — it makes the entry look younger, delaying a legitimate bump by at most the send duration. The read can never lose an already-broadcast tx: on error it falls back to the caller's block, and the > guard means a fallback never stamps backwards.

replaceStuck gets the same treatment. It previously reused the onBlock height, but a maintenance pass does a receipt read per entry and the replacement send does its own gas estimation, so a replacement could also be born stale and be re-bumped before it had stuckBlocks blocks to mine.

getBlockNumber is required, not optional. Unlike syncNonce / getConsumedNonce, which are genuinely opt-in features, this is a correctness requirement — an unwired caller would silently keep the buggy behaviour with no compile-time signal. All three queue constructors (midnight-liquidation, blue-liquidation, midnight-crossed-books) are wired using each bot's existing viem/actions accessor.

B. A fee bump forgot the hash it replaced.
replaceStuck overwrote entry.txHash, so the receipt sweep and reconcile() only ever looked at the replacement. When the original mined and the replacement didn't, the queue saw the nonce consumed with no receipt for the hash it was tracking and emitted nonce_consumed — discarding the real confirm/revert outcome. Entries now keep every hash broadcast for the nonce (newest first, naturally bounded by maxBumpAttempts):

 entry.txHash = replaced.data.txHash
+entry.txHashes.unshift(replaced.data.txHash)

onBlock settles on the first hash with a receipt and logs tx.confirmed / tx.reverted with the hash that actually mined; reconcile() drops nonce_consumed only when none of the tracked hashes has one. A read error during that scan still suppresses the drop, preserving the old "a transient RPC failure must not evict a live entry" semantics.

entry.txHash keeps meaning "latest broadcast" for replacement logic and for the tx.sent / tx.bumped / tx.dropped fields, so the log schema is unchanged apart from tx.confirmed / tx.reverted now carrying the mined hash, plus the new head.read_failed warn. Drop reasons, the stuckBlocks default, the nonce-hole latch, snapshot(), and inflightLabels() are untouched.

Testing

packages/bot-kit/test/queue/pending-queue.test.ts gains five cases, each verified to fail against the pre-fix behaviour (by reverting the logic under test, not by editing the assertion):

  • a stale caller block (0n) with a broadcast-time head of 12n is not bumped at block 13n — pre-fix 13 - 0 > 4 bumps
  • a rejected head read falls back to the caller block, logs head.read_failed, and still sends
  • a fetched head lower than the caller block keeps the caller block
  • a replacement stamped from the post-send head is not re-bumped on the next pass when the maintenance block is stale
  • after a bump, a receipt on the original hash settles as tx.confirmed with that hash and never emits nonce_consumed

bun run --filter @repo/bot-kit typecheck, bun lint (0 warnings), bun format, and bun test all pass; CI is green. The fork queue test stubs the accessor at 0n so its synthetic onBlock heights keep driving stuck-detection rather than the fork's real head.

Follow-up (not in this PR)

bots/midnight-liquidation/src/index.ts claims maintenance is kept "off the tick" so an API outage can't starve broadcast transactions of receipt checks and fee replacement. That is not true today: watcher.ts awaits each onBlock callback in sequence and runner.ts awaits the full tick(height) inside it, so a slow tick blocks all queue maintenance. It doesn't cause spurious bumps once the stamp is correct (stuck-detection is measured in block deltas), but it does delay confirmations, and the comment should either become true or be corrected. Worth its own ticket — decoupling them introduces real concurrency over the pending map.

Link to Devin session: https://app.devin.ai/sessions/ea20463be53f4b43b3818d5ebf5f8d4d
Requested by: @garethfuller

@garethfuller garethfuller self-assigned this Jul 31, 2026
@devin-ai-integration

Copy link
Copy Markdown
Original prompt from gareth

SYSTEM:
=== BEGIN THREAD HISTORY (in #gareth-ai) ===
<most_recent_message>
Gareth Fuller (U08ASENTN3T) [ts=1785482812.560399]: @Devin why are all the transactions on the production midnight bot failing but not in the staging env?
• prod dashboard - <https://telemetry.betterstack.com/team/t340147/dashboards/1074778?rf=now-7d&amp;amp;rt=now&amp;amp;top=1156&amp;amp;vs%5Bsource%5D=m2607569|midnight-liquidation>
• staging dashboard - <https://telemetry.betterstack.com/team/t340147/dashboards/1074778?rf=now-7d&amp;amp;rt=now&amp;amp;top=0|midnight-liquidation>
</most_recent_message>
=== END THREAD HISTORY ===
Channel ID: C0BJJNFCT3R
Thread URL: https://morpholabs.slack.com/archives/C0BJJNFCT3R/p1785482812560399?thread_ts=1785482812.560399&amp;cid=C0BJJNFCT3R

The latest message is the one right above that tagged you. The <most_recent_message> is the message that you should use to guide your goals + task for this session, and you should use the rest of the slack thread as context.
A [ts=...] marker on a Slack message is that message's timestamp. To act on a specific message with the slack tool (e.g. adding an emoji reaction via the reaction command), pass that value as timestamp along with the Channel ID — no extra lookup call is needed.

@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@garethfuller

Copy link
Copy Markdown
Collaborator Author

@claude review

@garethfuller

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 985771130c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}
let submittedAtBlock = args.blockNumber
if (getBlockNumber) {
const observed = await tryCatch(getBlockNumber())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stamp the head after the send completes

When getBlockNumber is read here it still happens before send(...); with the real signer, send then runs prepareTransactionRequest before sendTransaction, so a slow gas-estimation/RPC path can still advance more than stuckBlocks before the tx is actually broadcast. In that case the next maintenance pass can again see the entry as already stale and fee-bump it immediately, so the head should be sampled after a successful send (or at least maxed with a post-send sample).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreed, fixed in d8b61d7. The sample now happens after send resolves, so the gas-estimation window inside prepareTransactionRequest is covered. Sampling late is the safe direction — it can only make the entry look younger and delay a legitimate bump by the send duration, whereas sampling early reintroduces the bug in miniature.

* A slow tick can make its caller-supplied head many blocks stale and trigger stuck-detection
* immediately; read failures fall back to `submit.blockNumber` without preventing the send.
*/
getBlockNumber?: GetBlockNumber

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require or wire the broadcast head hook

Because this new hook is optional, queue consumers that are not updated keep the old tick-start stamping behavior silently; rg createPendingQueue\( shows bots/midnight-crossed-books/src/bootstrap.ts:70 still constructs the shared pending queue without getBlockNumber, while that bot also does market/book API work and simulation before queue.submit, so it remains exposed to the same immediate-bump/false-drop path this patch fixes. Make the hook required for production callers or wire the crossed-books queue as well.

AGENTS.md reference: AGENTS.md:L161-L169

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good catch — midnight-crossed-books was a genuine miss. Fixed in d8b61d7 by taking the stronger option: getBlockNumber is now required rather than optional, and all three constructors are wired. Optionality was the real defect here — unlike syncNonce / getConsumedNonce, which are opt-in features, this is a correctness requirement, so a silent fallback to the old behaviour is a footgun.

}
const oldHash = entry.txHash
entry.txHash = replaced.data.txHash
entry.txHashes.unshift(replaced.data.txHash)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh the head for replacement broadcasts

This successful replacement path still resets submittedAtBlock from the onBlock height rather than from a fresh head around the replacement send. If maintenance is catching up after a slow tick, that blockNumber can already be several blocks behind the actual broadcast, so the replacement is born stale and can be bumped again on the next pass before it has had stuckBlocks blocks to mine; apply the same broadcast-time stamping to replacements as to first sends.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreed, fixed in d8b61d7 — the replacement path now uses the same post-send sampling helper as the initial send, falling back to the onBlock height. Both call sites share one helper rather than duplicating the fallback/no-backwards logic.

const { logger, events } = captureLogger()
const { queue, sends } = setup({
getBlockNumber: async () => {
throw new Error('rpc down')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a typed error for this provider failure

This new case models an expected getBlockNumber/provider outage with a raw Error, but the repo rule requires expected infrastructure/provider failures to use named exported Error subclasses in their own kebab-case error files. Please reuse or introduce a typed error for the mock so the test does not add another plain expected failure path.

AGENTS.md reference: AGENTS.md:L40-L47

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Declining this one. The AGENTS.md rule governs expected failures raised by our own domain/application/infrastructure code, so that callers can discriminate them by type. This is a test stub imitating a third-party RPC outage: what the fallback actually catches at runtime is a viem/transport error class, not one of ours. Introducing a BotKit-owned error subclass purely to throw from a mock would make the test less faithful to the failure it models, and the assertion doesn't depend on the error's type — only that any rejection is swallowed, logged as head.read_failed, and doesn't lose the sent entry.

Comment on lines +118 to +122
/**
* Reads the chain head immediately before broadcast so `submittedAtBlock` reflects broadcast time.
* A slow tick can make its caller-supplied head many blocks stale and trigger stuck-detection
* immediately; read failures fall back to `submit.blockNumber` without preventing the send.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 This PR wires the new broadcast-time getBlockNumber fix into blue-liquidation and midnight-liquidation, but bots/midnight-crossed-books/src/bootstrap.ts (line ~70) also builds createPendingQueue and is left unwired, so it still stamps submittedAtBlock with the stale tick-start head. This re-exposes the CRTR-2851 root cause for that bot: a slow scan tick can trigger an instant, spurious fee-bump on the very next block. Since chainClient and getBlockNumber are already imported/used in this file, wiring getBlockNumber: () => getBlockNumber(chainClient) into the queue constructor is a one-line fix.

Extended reasoning...

The bug: createPendingQueue's new getBlockNumber accessor (packages/bot-kit/src/queue/pending-queue.ts:118-122) is optional, and this PR only threads it through two of the three bots that construct a shared queue. bots/midnight-crossed-books/src/bootstrap.ts:70-77 builds its queue with send, getReceipt, getBaseFee, syncNonce, getConsumedNonce, maxFeeWei, and logger — but no getBlockNumber. Because the accessor is optional, submit() silently falls through to the args.blockNumber fallback, which is exactly the pre-fix (buggy) behavior this PR is otherwise eliminating.

Code path that triggers it: bootstrap.ts's runner tick calls bot.run({ blockNumber }) with the tick-start head (captured once per tick, before any I/O). CrossedBooksBotService.run iterates markets — each doing an order-book API round trip via RouterApiService, then matching, then an on-chain resolver.simulate call — before finally calling resolver.submit(prepared, blockNumber) (resolver.service.ts:32), which flows into ViemResolverTransport.submit (resolver.transport.ts:51-59) and then queue.submit({ ..., blockNumber }). That chain of HTTP + RPC round trips can easily span multiple ~2s Base blocks on a cold or slow tick, exactly the scenario that caused the original nonce_consumed false-failures.

Why the existing code doesn't prevent it: the queue's own defense — reading the head fresh right before broadcast — depends entirely on the caller wiring getBlockNumber in. Since it's an optional parameter with a silent fallback, a caller can omit it with no compile-time or runtime signal that anything is missing. This bot already imports getBlockNumber from viem/actions and uses it for the runner's own block-watch loop (bootstrap.ts:16,115), so the omission at the queue construction site looks like it was simply missed when porting the fix from the two liquidators.

Impact: with the stale tick-start stamp, submittedAtBlock can already be several blocks old by the time the tx is actually pending. The very next onBlock can satisfy blockNumber - entry.submittedAtBlock > stuckBlocks (4) and fire an immediate, spurious fee-bump — before the original transaction had any real chance to mine. Note that defect B from this PR (the txHashes receipt-history fix) is shared queue code, so it does apply to crossed-books regardless of this wiring gap — meaning the catastrophic false nonce_consumed drop that hit midnight-liquidation in production is not fully reproduced here. The residual harm is premature/spurious fee-bumps, wasted gas on replacement transactions, and potentially exhausting maxBumpAttempts and dropping a resolve that would otherwise have mined.

Step-by-step proof:

  1. Tick starts at block N; bot.run({ blockNumber: N }) is invoked.
  2. The bot fetches listed markets, order books (external API), matches crossed positions, and calls resolver.simulate (RPC) — say this takes ~9s, spanning ~4-5 Base blocks (chain is now at block N+5).
  3. Simulation succeeds; resolver.submit(prepared, N) is called with the now-stale block N.
  4. queue.submit has no getBlockNumber, so submittedAtBlock = N (the fallback).
  5. The broadcast lands in the mempool at real time corresponding to block N+5.
  6. The very next onBlock(N+6) maintenance pass checks (N+6) - N = 6 > stuckBlocks (4) → true, and immediately replaces (bumps) a transaction that hasn't even had one full block to confirm.

Fix: mirror the two liquidators — pass getBlockNumber: () => getBlockNumber(chainClient) into the createPendingQueue call in bootstrap.ts. Both the import and the client are already present in this file, so it's a one-line addition.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Correct, and the analysis of the crossed-books path matches what I found — fixed in d8b61d7. Rather than the one-line wiring, getBlockNumber is now a required option so an unwired caller is a compile error instead of a silent fallback to the old behaviour; all three constructors are wired. Your read on the blast radius is right too: defect B is shared queue code, so crossed-books was already covered there, and the residual exposure was spurious bumps and wasted gas rather than false nonce_consumed drops.

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.

1 participant