Skip to content

feat: lead the fee quote with the fee the next block will charge - #25394

Open
spalladino wants to merge 1 commit into
spl/next-block-predictorfrom
spl/fee-quote-next-block-head
Open

feat: lead the fee quote with the fee the next block will charge#25394
spalladino wants to merge 1 commit into
spl/next-block-predictorfrom
spl/fee-quote-next-block-head

Conversation

@spalladino

@spalladino spalladino commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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

Last of the fee-quote series, stacked on #25393. Fixes Bug 2 of #25344 / A-1885: the fee frozen into an in-progress checkpoint is invisible to a quote built only from forward-looking L1 projections.

The bug

Every block in a checkpoint carries the fee frozen into the checkpoint's first block. The public simulation honours that; the fee quote did not — it only asked the L1 fee provider what a future slot would cost.

Worked example, with the L1 gas oracle stepping down between two slots:

  • A checkpoint opens at slot 22 and freezes a mana min fee of 3,415 into its first block.
  • The oracle steps down, so slot 23 onwards prices at 920.
  • A wallet asks for a quote while that checkpoint is still in progress. The projections answer 920; the wallet's default 1.5x padding takes it to 1,380.
  • The next block continues the checkpoint, so it charges 3,415. The transaction is rejected for paying too little, on a fee the node itself quoted.

The fix

AztecNodeService.getPredictedMinFees now asks the next-block predictor for the fee the next block will actually charge and puts it first:

  • Mid-checkpoint that fee is copied from the in-progress checkpoint's header — no L1 involved.
  • At a checkpoint boundary it is the background-refreshed boundary fee.
  • The projections are then requested asOf the same L1 sync point the head was derived from, so both halves of the answer describe one L1 block instead of two independently polled views.

Wallets already reduce the list with max (wallet SDK getMinFees, the CLI, the e2e fixture), so they pick up the head automatically and a quoted transaction clears the simulation's fee check.

Why the quote waits at a transition

At a boundary the head can miss the shared fee cache and waits up to QUOTE_MAX_WAIT_MS (5s) for the refresh — the same L1 call the background loop was about to make, moved earlier and single-flighted, so a burst of quotes during a transition costs one round trip. The projections' tagged wait shares that budget (it gets whatever the head left of the 5s), so the whole quote is bounded by 5s rather than 10s.

Skipping the head instead would be wrong, because fees go up as well as down. The head is priced with the pipelining overrides, which include the proposed parent's real mana usage; the projections only approximate that with an assumed usage. When the parent ran hot the head exceeds every projection, and omitting it would underquote.

Degraded path

If quoteMinFees cannot price the next block in time (an L1 outage past the cache's staleness cutoff, a refresh that outlasts the 5s budget, or a frontier with no header for its proposed tip) the node serves the projections alone, untagged — exactly the answer it gave before this change. The predictor logs the cause (a warning for a missing header, debug for a timed-out refresh); an outright throw from the predictor is logged as a warning here.

API note for consumers

The first entry of getPredictedMinFees is now the fee the node's public simulation would charge for the next block, not "the current min fee". Clients that reduce the list with max are unaffected. A client that read fees[0] as the current fee should call getCurrentMinFees instead. getCurrentMinFees and tx admission (isValidTx) are unchanged. Documented in the migration notes.

Tests

  • Restored the frozen-fee integration case in fee_quote_vs_simulation.integration.test.ts: mid-checkpoint, the quote's first entry equals the frozen header fee, the simulation charges the same fee, and the padded quote covers it. Verified red on refactor: move next-block planning and the boundary fee cache out of the public calls simulator #25393 (quote head absent, quote 360,947,600,000 vs frozen 360,937,510,100,000) and green here.
  • The remaining integration cases (lagging clock, nothing lagging, cached boundary fee, checkpoint landing) stay green; the "nothing lagging" case now also asserts the quote head equals the fee the simulation charges.
  • Server unit tests for the head being prepended and the projections tagged with the head's sync point, the untagged path when there is no sync point, the projections-only paths when the head is undefined or throws, and that getCurrentMinFees and isValidTx do not see the head.

@AztecBot

AztecBot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Flakey Tests

🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry.

\033FLAKED\033 (8;;http://ci.aztec-labs.com/6286ca5e972a1fa5�6286ca5e972a1fa58;;�): yarn-project/kv-store/scripts/run_test.sh src/bench/sqlite-opfs-encrypted/map_bench.test.ts (1s) (code: 0)

@spalladino
spalladino force-pushed the spl/fee-quote-next-block-head branch from 7be207d to b97b601 Compare September 2, 2026 23:09
@spalladino
spalladino force-pushed the spl/fee-quote-next-block-head branch from b97b601 to 5005e80 Compare September 2, 2026 23:22
spalladino added a commit that referenced this pull request Sep 7, 2026
Fixes #25344. Fixes A-1885. Part of A-1903.
Design:
https://claude.ai/code/artifact/e9b78bd2-501d-4c30-a81a-5a10cbeed392

## The symptom

On a fresh sandbox, a wallet asks the node what fee to pay, pads it by
50%, and sends the transaction to `simulatePublicCalls`. Sometimes the
node's own simulation rejects it:

```
maxFeesPerGas.feePerL2Gas must be greater than or equal to gasFees.feePerL2Gas,
but got maxFeesPerGas.feePerL2Gas=1058030306 and gasFees.feePerL2Gas=3415500000
```

The wallet paid exactly what the node told it to pay.

## Where the fee number comes from

Both the wallet quote and the simulation ask the L1 rollup contract the
same question — *"what is the minimum mana fee for a block in slot X?"*
(`Rollup.getManaMinFeeAt`). The answer depends on an L1 gas oracle
updated when checkpoints are proposed, and the new value only kicks in a
couple of slots later, so the answer is a **step function of the slot**.
While the sandbox's anvil base fee is decaying (the first minutes after
start) each step is a large drop — in the issue, 3,415,500,000 →
920,600,000.

The two sides therefore only agree if they ask about the **same slot**.
They didn't.

## The bug: the simulator could target a slot that was already taken

The fee quote (`FeeProviderImpl`) picks its slot as `max(slot of the
latest checkpoint on L1 + 1, next slot by the node clock)` — anchored to
**L1**.

The simulator (`NodePublicCallsSimulator.computeTargetSlot`) picked
`max(next slot by the node clock + pipelining offset, slot of the
locally proposed checkpoint + 1)` — anchored to the **node clock**, and
that second term disappears once the archiver promotes the proposed
checkpoint to checkpointed.

Worked example (72s slots, the oracle steps at slot 15):

- The sandbox builds checkpoint 14, sends it to L1, anvil mines it. L1
now says: latest checkpoint is at slot 14. The node's clock has not been
bumped yet — the automine sequencer only advances it at the very end of
its publish routine.
- Fee quote: the latest L1 checkpoint is at slot 14, so the next block
is slot 15 at the earliest → fee for slot 15 → cheap (post-step) → the
wallet declares 1.5x that.
- Simulator: the node clock says the next slot is 13, plus the
pipelining offset → slot 14. There is no proposed checkpoint any more
(already promoted) → fee for slot 14 → expensive (pre-step).
- expensive > 1.5 x cheap → the assertion fires.

Slot 14 is nonsense for the simulator to target: a checkpoint already
exists there on L1, so the next block can only land in slot 15 or later.
The simulator didn't know, because it trusted its clock over the chain.

## The fix

`computeTargetSlot` gains a third term in its `max`: **slot of the
latest checkpointed checkpoint + 1**.

- The slot is read from the checkpointed tip's block header **by the
tip's block hash**, not by its number, so a checkpoint unwind that
replaces the block at that number cannot silently answer with a
different block's slot. A miss means the archiver no longer holds the
block its own tips name — a torn snapshot — and throws a retryable error
rather than dropping the floor.
- The term never lowers a correct answer: when the clock is ahead, as it
normally is, the clock term is already larger. It only binds when the
clock is behind the chain, which is exactly the broken case. In the
example above, `max(14, 15) = 15` — the same slot the quote used.
- Before the first checkpoint lands the checkpointed tip is the genesis
block, which the archiver does not store; there is no slot taken yet, so
the floor is skipped rather than failing the simulation.

Nothing else changes: no new L1 call on any path, no fee RPC touched,
and transaction admission (`isValidTx`, the p2p validators) is
untouched.

## What this does not fix

- **The residual L1-poller window.** The fee provider and the archiver
each run their own L1 poll, so they can briefly hold different views of
which checkpoints exist on L1, and the quote and the simulation can
still disagree across that window. A follow-up PR drives the fee
provider from the archiver's L1 sync point and pins every fee read to
that L1 block, which closes it.
- **The frozen mid-checkpoint fee (Bug 2).** All blocks in a checkpoint
share the fee frozen into its first block. The simulator honours that
(it copies the latest proposed block's header); the quote only looks at
forward-looking L1 projections and never sees the frozen value, so a
correctly-priced transaction can still fail simulation on a network with
multi-block checkpoints when fees are falling fast. This is not
reachable on the sandbox (one block per checkpoint) and was not the
reported issue. A later PR in this stack makes the quote lead with the
fee the next block will actually charge, which fixes it.

## Tests

-
`aztec-node/src/aztec-node/fee_quote_vs_simulation.integration.test.ts`
(new): real anvil, real L1 contracts, `RollupContract`, `EpochCache`,
`GlobalVariableBuilder`, `FeeProviderImpl`,
`AztecNodeService`/`NodePublicCallsSimulator`, and a real
`PublicProcessor` on a real world-state fork; only the archiver is
mocked. It steps the oracle 1000 gwei → 1 gwei (~1000x fee step) and
plants the L1 pending checkpoint at the slot before the step via storage
cheats. Two cases: a lagging node clock (reproduces the exact assertion
from the issue without the fix) and nothing lagging (quote and
simulation already agree, and must keep agreeing).
- Unit tests in `node_public_calls_simulator.test.ts`: the floor binds
when the clock lags; it does not raise the slot when the clock is ahead;
the checkpointed tip is read by hash and not by number; a missing tip
block throws a retryable error; the floor is skipped at genesis.
- `RollupCheatCodes.setPendingCheckpoint` extracted from
`fee_predictor.test.ts` so both suites plant a pending checkpoint the
same way.

## Stack

#25384 (atomic archiver `L2Frontier` snapshot, A-1897) is stacked on top
of this PR. It replaces the by-hash tip-header read added here with a
field of the snapshot, so the slot and the overrides plan come from one
atomic archiver read.

This is the bottom PR of the five-PR fee-quote / public-simulation
series (GitHub stack #25385), merged in order:

- #25357 — slot floor (this PR, A-1885)
- #25384 — atomic archiver `L2Frontier` snapshot (A-1897)
- #25392 — fee provider driven by the archiver's L1 sync point (A-1904)
- #25393 — next-block predictor, planning and fee cache out of the
simulator (A-1905)
- #25394 — quote leads with the fee the next block will charge (A-1906)
@spalladino
spalladino force-pushed the spl/fee-quote-next-block-head branch from 5005e80 to 94f500b Compare September 7, 2026 17:10
All blocks in a checkpoint share the fee frozen into its first block, so
mid-checkpoint the fee a wallet was quoted from forward-looking L1
projections can sit far below the fee the node's own simulation charges,
and the quoted transaction is rejected for paying too little.

getPredictedMinFees now leads with the next-block predictor's quote and
tags the projections with the same L1 sync point, so both halves of the
answer describe one L1 block. Both waits share one 5s budget. The head is
omitted when the node cannot price the next block in time, leaving the
projections as before.
@spalladino
spalladino force-pushed the spl/fee-quote-next-block-head branch from 94f500b to d28592c Compare September 7, 2026 18:24
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