Skip to content

refactor: move next-block planning and the boundary fee cache out of the public calls simulator - #25393

Open
spalladino wants to merge 1 commit into
spl/fee-provider-archiver-syncfrom
spl/next-block-predictor
Open

refactor: move next-block planning and the boundary fee cache out of the public calls simulator#25393
spalladino wants to merge 1 commit into
spl/fee-provider-archiver-syncfrom
spl/next-block-predictor

Conversation

@spalladino

@spalladino spalladino commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes A-1905. Part of A-1903.
Design: https://claude.ai/code/artifact/e9b78bd2-501d-4c30-a81a-5a10cbeed392

Stacked on #25392. Fourth PR of the fee-quote series. No
behaviour change for RPC callers except that they no longer cause an L1 call per request.

The structural problem

Three components each formed their own idea of "the next block and its fee": the sequencer, the L1-only fee
provider, and the public calls simulator. The simulator's copy lived inline in simulate, and at a checkpoint
boundary it priced the block by calling L1 on every request. An abusive L2 RPC user could therefore turn
simulatePublicCalls traffic into pressure on the node's own L1 RPC — the opposite of what an RPC node should
do with a shared, rate-limited upstream.

The decomposition

A new aztec-node/src/aztec-node/next_block/ module, consumed only by RPC-side code:

  • next_block_planner.ts — pure functions. planNextBlock(frontier, clockSlot) decides whether the next
    block continues the in-progress checkpoint or opens a fresh one, computes the three-term target slot (this
    node's clock, the proposed parent's slot + 1, the checkpointed tip's slot + 1 as a floor), the target
    checkpoint, and the block the plan builds on. computeBoundaryFeeKey turns a checkpoint-opening plan into a
    typed key. No I/O.
  • next_block_fee_cache.tsNextBlockFeeCache owns the one L1-derived value: the checkpoint globals a
    block opening a fresh checkpoint would carry. It keeps the current and previous records, refreshes them on a
    RunningPromise loop, and builds the same SimulationOverridesPlan the sequencer applies (moved here from
    the simulator, no-rollup-contract fallback included).
  • next_block_predictor.tsNextBlockPredictor, the façade. predict() returns the plan, the frontier
    it came from, and the globals the next block would carry. quoteMinFees() returns the fee that block will
    charge. Both re-derive the plan; only the fee comes from the cache.

The simulator shrinks to what it should be: check the gas limit, ask the predictor, sync the world state to the
block the plan builds on (by number and hash, replanning once if a prune moved the chain underneath), fork it,
insert the L1-to-L2 messages a checkpoint-opening block would see, run the processor.

The predictor is constructed and started in createAztecNodeService like every other node service, and injected
into AztecNodeService, which only stops it.

Why the plan is re-derived and only the fee is cached

The next block combines two clocks: rapidly moving L2 execution state and much slower L1 fee state. Caching a
whole { plan, globals } object would freeze the fork block, the target checkpoint (so the wrong checkpoint's
L1-to-L2 messages get appended), and the mid-checkpoint frozen-fee detection, for up to one refresh interval.

Worked example, 1s refresh, fee step 3,415.5M → 920.6M. At t=0.00 the loop caches "opens checkpoint 8 at slot
23, fee 920.6M". At t=0.40 block 42 arrives over gossip: the first block of checkpoint 8, built at slot 22
with 3,415.5M frozen into its header. A request at t=0.55 served from a cached plan answers 920.6M — wrong;
the wallet pads to 1,380.9M and the tx cannot enter block 43. With a fresh plan the node sees it is
mid-checkpoint, copies the header, answers 3,415.5M, and involves no L1 at all. Archiver-driven changes carry
the largest fee jumps, so those must be seen instantly; L1-driven changes lag by at most one refresh, the same
lag the fee provider already has.

Re-planning is cheap: the frontier is one in-memory read from the archiver's cache, the clock slot is
arithmetic, and the mid-checkpoint header arrives with the frontier.

The cache key

Records are looked up by a logical key: target slot, checkpointed checkpoint number, the block the plan builds
on, and either the proposed parent's fee-relevant fields (header hash, archive root, checkpoint out hash,
total mana used, fee asset price modifier) or the pending chain's validity. The L1 block a record was priced at
is stored with it for pinning, but is deliberately not part of the lookup: the min fee for a fixed slot and
parent depends only on rollup storage, and the rollup transactions that move it (checkpoints landing,
invalidations, prunes) all move the frontier and therefore the key. So a new L1 block on its own is a hit, and a
miss means a real transition — a slot rollover, a checkpoint landing or being proposed, a validity flip. The one
exception is a governance parameter update such as the mana target, which changes the fee without touching the
frontier; the background pass re-prices a matching record whenever the L1 anchor moves, so that lags by at most
one refresh interval, the same lag the fee provider has.

A pass that confirms nothing moved re-stamps the record instead of re-pricing it, which is what makes the
staleness cutoff mean "how long we have been unable to confirm" rather than expiring a value that is still
exactly right.

Single-flight and the wait policy

The rule the cache exists to enforce: the request path never originates an L1 call the background loop would
not make, and never has more than one in flight. A refresh is single-flight and shared between the loop and
every request, so a burst of requests during a transition costs one L1 round trip.

  • Simulation waits without a cap and surfaces the L1 failure, exactly as it did before this series.
  • The quote caps its wait at 5s (QUOTE_MAX_WAIT_MS) and never throws: on a timeout or failure it serves
    the matching record if it is under the staleness cutoff, otherwise nothing. During an L1 outage the shared
    call hangs until the L1 RPC timeout, and a quote that waits on it would become a multi-second RPC exactly
    when users are already struggling.
  • The cutoff is ten refresh intervals: one failed pass is a hiccup and a ten-second-old boundary fee is almost
    always still right, but after a long outage the fee may have stepped and serving it would underquote.

The sequencer is untouched

NextBlockPredictor lives in aztec-node and is consumed only by RPC code. The sequencer computes its own
plan from the same frontier and the same overrides builder, with a deliberately different slot policy (it
declines to build when its clock slot is taken; the RPC side predicts inclusion and adds two floors), and must
never read this cache — a stale fee would make L1 reject its checkpoint. Extracting a shared "describe the next
checkpoint" helper is a reasonable follow-up, not part of this work.

Residuals

  • The overrides plan the cache builds still reads the grandparent checkpoint and the mana target at L1 head
    (inside buildCheckpointSimulationOverridesPlan); only the final fee read is pinned to the frontier's sync
    point. Same as before this series, and shared with the sequencer. Pinning those reads means threading the block
    number through the stdlib helper and is a follow-up.
  • Pinning is by L1 block number, so a same-height L1 reorg is not distinguishable by the cache or the fee
    provider until the archiver's next pass reconciles the frontier. The window is one archiver pass, and was the
    same before this series when fees were read at latest.
  • A refresh that stalls on L1 keeps the single-flight slot until the L1 client's own HTTP timeout
    (l1HttpTimeoutMS) fires; the capped quote gives up on its own after 5s, so only simulations wait that long.

What RPC callers observe

Nothing changes, except that repeated simulatePublicCalls requests at a checkpoint boundary no longer each
perform an L1 call. getPredictedMinFees is not touched here; the next PR makes the quote lead with
quoteMinFees().

Tests

  • next_block_planner.test.ts: continues vs opens; each of the three slot terms and the genesis case; the key
    is undefined mid-checkpoint, stable when nothing moves, and moves on the target slot, the checkpointed
    checkpoint, the latest block hash, a validity flip, a changed first-invalid checkpoint, a proposed parent
    replacing the checkpointed one, and each proposed-parent fee field.
  • next_block_fee_cache.test.ts: skips the re-price when nothing moved; re-prices on a key move while still
    serving the boundary it just left; re-prices in the background on an L1 anchor move without a reader miss;
    one in-flight refresh shared by two readers and a loop pass; the capped wait serving a record under the
    cutoff and nothing past it; the uncapped wait rethrowing; idempotent start; start surviving a failed priming
    pass; the overrides plan for the idle, pipelined and invalid-chain shapes, with and without a rollup contract.
  • next_block_predictor.test.ts: the mid-checkpoint copy with no cache call, boundary globals with zero payout
    addresses, the missing-header rejection, and the three quoteMinFees outcomes.
  • node_public_calls_simulator.test.ts: rewritten against a mocked predictor — forks the block the plan builds
    on, hands the processor the predictor's globals, appends messages only at a boundary, replans once when the
    sync reports a block-hash mismatch and fails with a retryable error when it persists, propagates other sync
    failures untouched, plus the existing gas-limit and L1ToL2MessagesNotReadyError cases.
  • fee_quote_vs_simulation.integration.test.ts (anvil) keeps its three cases and gains one that fails before
    this change: two simulations with nothing moving in between price the boundary once, not twice.

…the public calls simulator

Three components each formed their own idea of the next block, and the public calls simulator hit L1 on
every request at a checkpoint boundary, so abusive L2 RPC traffic turned into pressure on the node's L1 RPC.

Splits that work into aztec-node/src/aztec-node/next_block/: a pure planner (continues-vs-opens, the
three-term target slot, the boundary fee key), a NextBlockFeeCache that owns the one L1-derived value with a
single-flight background refresh, and a NextBlockPredictor facade the simulator consumes. The plan is
re-derived from a fresh archiver snapshot per request; only the boundary fee is cached, keyed logically so a
new L1 block alone is a hit and a miss means a real transition. The predictor is built and started by the
node factory like every other service.

The simulator now syncs the world state to the plan's block by number and hash, replanning once if a prune
moved the chain underneath, then forks, inserts messages, and executes. No behaviour change for RPC callers
beyond the removed per-request L1 calls; the sequencer is untouched.
@spalladino
spalladino force-pushed the spl/next-block-predictor branch from 61ffbb7 to 9cd2b10 Compare September 2, 2026 23:22
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