Skip to content

perf(rpc): rebuild the block template when the mempool changes - #11374

Draft
upbqdn wants to merge 2 commits into
11370-precomputed-block-templatefrom
5891-mempool-change-rebuild
Draft

perf(rpc): rebuild the block template when the mempool changes#11374
upbqdn wants to merge 2 commits into
11370-precomputed-block-templatefrom
5891-mempool-change-rebuild

Conversation

@upbqdn

@upbqdn upbqdn commented Sep 2, 2026

Copy link
Copy Markdown
Member

Based on #11371's branch, not main: that PR moves template production into the block template updater task, and this changes what makes that task rebuild. Please review and merge #11371 first.

Motivation

Part of #5891.

Solution

The updater task subscribes to the mempool change channel instead of rebuilding
on a five second timer, and a long polling getblocktemplate call returns when
the updater publishes a template instead of waiting for its own mempool poll.

A rebuild reads the whole mempool, re-runs ZIP-317 selection, and rebuilds the
coinbase transaction, so it costs far more than the notification that triggers
it. Changes that cannot alter the template do not cause one:

  • A burst of additions coalesces into one rebuild, debounced by 500 ms. The
    debounce is also what Detect mempool changes for getblocktemplate long polling with a channel聽#5891 asks for after a chain fork: the mempool resets
    and re-verifies, so waiting lets re-verified transactions rejoin the template
    instead of publishing the emptied mempool.
  • Invalidated also fires for transactions that failed verification and were
    never in the mempool, so it only rebuilds when the template's long poll ID
    covers the transaction. That ID is derived from every ID in the mempool
    rather than the transactions ZIP-317 selected, so the cache keeps the whole
    set: a template built from a mempool that no longer exists must be replaced
    even for a transaction that was never selected.
  • Overflowing the channel compares the mempool against that set rather than
    rebuilding. Debouncing alone would not help, because a peer sending invalid
    transactions can keep sending: without the comparison, making the channel lag
    would buy the rebuilds the filter denies.
  • Mined arrives with the chain tip change that mined it, which rebuilds
    anyway.

A thirty second backstop keeps cur_time current, which Testnet's
minimum-difficulty rule depends on, and bounds a lost notification. It replaces
a five second poll, so an idle node does six times less of this work.

This does not close #5891 on its own. The mempool announces newly verified
transactions from its poll_ready, so a change is announced only when the
mempool is next polled, which the queue checker guarantees at a five second
rate limit. That is the remaining fixed interval the issue asks to remove, and
it needs a notification from the verifier rather than a change here.

Tests

  • A burst of twenty additions rebuilds the template once. Without the debounce,
    it rebuilds twenty times.
  • Invalidating transactions the template was not built from does not rebuild
    it, so rejected transactions cannot force rebuilds. Making Invalidated
    always rebuild fails this.
  • Invalidating a transaction ZIP-317 left out of the template does rebuild it,
    because the template's long poll ID still covers it. Checking the selected
    transactions instead of the mempool ID set fails this.
  • Overflowing the change channel while the mempool still holds what the
    template was built from does not rebuild it. Treating a lagged channel as a
    change fails this.
  • A long polling call returns once the updater publishes, well inside the RPC's
    own poll interval. Without the new wake-up it waits for that interval.

On a Regtest node, rejecting an invalid transaction broadcasts the change and a
long poll stays asleep afterwards; long polling still wakes on a chain tip
change; and an idle node holds a long poll open without returning or spinning.


  • This change was discussed in an issue or with the team beforehand.
  • The solution is tested.
  • The documentation and changelogs are up to date.

AI disclosure: written with Claude Code.

@upbqdn upbqdn self-assigned this Sep 2, 2026
@v12-auditor

v12-auditor Bot commented Sep 2, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found eight issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-263366 馃數 Low
Testnet cache serves stale difficulty

On an idle Testnet tip, a template built at or just below the minimum-difficulty time boundary contains standard-difficulty bits and a max_time capped at that boundary. Once wall time advances past the boundary, a fresh chain-info query would return minimum difficulty, but matching-tip cache lookup performs no timestamp or difficulty freshness check and the updater does not query again until its 30-second backstop. The template's clock-derived fields are captured before potentially expensive transaction selection and coinbase construction, so it can already be stale when published, after which the backstop timer starts. A miner obeying the cached max_time must continue using the old timestamp and standard difficulty; a miner advancing the advertised mutable time past the boundary while retaining the cached bits produces a block whose difficulty does not match that timestamp. The longer idle refresh therefore withholds usable current-time minimum-difficulty work during the condition that rule is intended to mitigate.

F-263368 馃數 Low
Lag recovery suspends updater deadlines

wait_for_change() races chain-tip changes, mempool notifications, and the backstop only in its outer tokio::select!. When broadcast lag is detected, the selected branch leaves that race and awaits current_mempool_tx_ids(), which performs a Tower oneshot(TransactionIds) without a response timeout. Until that request completes, neither the chain-tip watcher nor the already-running 30-second backstop is polled. Production wraps the mempool in a bounded Buffer but applies no timeout, so a delayed or stalled buffered request suspends both independent updater deadlines. The concrete mempool answers this query immediately after dispatch, limiting normal production delay, but no invariant guarantees completion once the request is waiting behind or dependent on a stalled service.

F-263369 馃數 Low
Tip changes detach obsolete builds

A relevant mempool event can wake the updater, after which it races build_with_mempool against best_tip_changed(). If build() has reached spawn_blocking and the tip branch wins, continue drops the build future and its JoinHandle but does not stop the blocking closure. That closure has no cooperative cancellation or stale-tip check and continues transaction selection, coinbase construction, and root calculation for the obsolete tip. A later iteration can launch a build for the new tip while the abandoned closure still runs. The select/spawn non-cancellation existed previously, but event-driven rebuilds after transaction additions increase the time windows in which accepted tip changes can detach obsolete work.

F-263370 馃數 Low
Collateral evictions leave cache stale

A production insertion can exceed tx_cost_limit, randomly remove existing mempool transactions, and then randomly evict the newly inserted transaction and return Err(RandomlyEvicted). The broadcaster consequently emits Invalidated only for the attempted transaction because the insertion API does not return collateral victim IDs. For a template built before the insertion, that new ID is absent from the cached mempool set, so affects_template() ignores the only notification even though older cached transactions were removed. The non-lagged path performs no current-mempool reconciliation after ignoring such an event. The cached set and its derived long-poll ID therefore remain inconsistent with the actual mempool until another relevant event or the backstop rebuild.

F-263371 馃煛 Medium
Invalid traffic starves template refresh

A sustained stream of Invalidated notifications for transaction IDs absent from the cached mempool can keep mempool_changes.recv() continuously ready. Because the tokio::select! is biased and polls that receiver before the backstop, each notification wins and the Ok(_) => continue path immediately starts another biased selection without testing the elapsed deadline. Channel lag does not restore the bound: rejected transactions leave the current mempool ID snapshot equal to the cached set, and lag recovery also continues into the same priority order. The code itself treats continuously submitted invalid transactions as a practical peer-controlled stream. On a stalled Testnet chain, traffic started before the minimum-difficulty transition can therefore keep the cached standard-difficulty template and old clock fields indefinitely instead of refreshing after 30 seconds.

F-263372 馃煛 Medium
Overflow retains stale rebuild triggers

The three notification drains use while mempool_changes.try_recv().is_ok() {}, so they stop on TryRecvError::Lagged as though the receiver were empty. Tokio advances a lagged receiver to the oldest retained message, leaving retained notifications available for the next receive. If the bounded channel overflows during the 500 ms debounce, the updater can rebuild from the current mempool and publish while older retained Added notifications remain unread. The next wait_for_change() receives one of those additions, and affects_template() unconditionally treats it as relevant even though the just-published snapshot already contains it. A single overflow burst can therefore trigger redundant full template builds without any intervening mempool state change.

F-263373 馃煛 Medium
Valid additions sustain template proving

On a synced mining node, a remote peer can submit valid transactions serially and wait for each verification to complete. Every successful insertion emits MempoolChangeKind::Added, and the updater treats every addition as template-affecting without first determining whether ZIP-317 will select it. After only the 500 ms debounce, the updater copies the entire mempool and reruns ZIP-317 selection and template construction. When an accepted transaction changes the selected fee total, the fee-keyed coinbase cache misses; a shielded miner address then generates a new shielded coinbase proof. Per-peer admission limits only concurrent work and release their slots on completion, while the mempool cost limit evicts transactions rather than preventing later valid admission, so a serial valid stream can sustain the expensive path.

F-263375 馃煛 Medium
Template publications trigger per-waiter template rebuilds

Long-poll requests whose supplied ID matches the cached template enter the loop after the one-time precomputed-template check. Each iteration fetches fresh chain information and the full mempool, and then waits on a separately subscribed TemplateCache::changed() receiver. When the updater finishes a build and publishes it, all receivers wake, but the new-template branch only logs and falls through to repeat the loop rather than serving the already-published template. Consequently, every waiter repeats the expensive reads, randomized transaction selection, and template construction that the updater has already completed. The updater's periodic backstop can also publish successfully rebuilt templates even when the LongPollInput identity is unchanged, creating additional wakeups for work that does not necessarily represent new long-poll work.

And two more auto-invalidated findings.

Analyzed three files, diff ae626ac...c4e8c6e.

@upbqdn

upbqdn commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Heads up on the checks: this targets #11371's branch, and Zebra's lint, pr-gate, tests-unit, test-crates, test-docker and integration workflows are all filtered to pull_request.branches: [main]. So the 10 green checks here come only from the unfiltered workflows, and the aggregators that gate merging never ran. They will once #11371 merges and this retargets.

@upbqdn

upbqdn commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Staying a draft on purpose: its base is #11371's branch, and marking it ready would let an approval merge it into that branch, which would fold these commits into #11371's diff while that PR is still under review. It retargets to main and goes ready once #11371 merges.

Also opened #11376 for the other half of #5891, based on main and independent of this stack: the mempool only stores, gossips, and announces a verified transaction from its poll_ready, so until that lands, the queue checker's five second rate limit still bounds how soon a new transaction reaches a template on a quiet node. Neither PR closes #5891 alone.

The block template updater task rebuilt every five seconds whether or not
anything had changed, so a new transaction waited up to that long to reach
miners while an idle chain paid for rebuilds nobody needed. Subscribe to the
mempool change channel instead: additions rebuild, and a burst of them
coalesces into one rebuild.

A rebuild reads the whole mempool, re-runs ZIP-317 selection, and rebuilds the
coinbase, so changes that cannot alter the template do not trigger one. The
`Invalidated` kind also fires for transactions that failed verification and were
never in the mempool, so it only rebuilds when the template's long poll ID
covers the transaction. That ID is derived from every ID in the mempool rather
than the transactions ZIP-317 selected, so the cache stores the whole set.

Overflowing the change channel compares the mempool with that set rather than
rebuilding, or a peer sending invalid transactions fast enough to make the
channel lag would buy the rebuilds the filter denies it.

A thirty second backstop keeps `cur_time` current, which Testnet's
minimum-difficulty rule depends on, and bounds a lost notification.
A long polling `getblocktemplate` call waited on its own five second mempool
poll, so the updater task's fresh template sat unused until that timer elapsed.
Wait on the template cache as well, which the previous commit rebuilds when the
mempool changes.

The poll stays as a backstop for a node whose updater task isn't running, and
waiting forever on a dropped sender keeps a template cache without a task from
spinning the loop.
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.

Detect mempool changes for getblocktemplate long polling with a channel

1 participant