Skip to content

feat(node): serve fee RPCs from a background L1 fee snapshot - #24970

Draft
spalladino wants to merge 8 commits into
merge-train/spartan-v5from
spl/fee-snapshot-service
Draft

feat(node): serve fee RPCs from a background L1 fee snapshot#24970
spalladino wants to merge 8 commits into
merge-train/spartan-v5from
spl/fee-snapshot-service

Conversation

@spalladino

@spalladino spalladino commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Makes warm getPredictedMinFees/getCurrentMinFees calls zero-L1-request in-memory reads, so quote latency no longer scales with L1 RPC latency.

Context

Every getPredictedMinFees call paid two uncached eth_blockNumber round trips, and each new L1 block added up to ~8 sequential eth_call waves on the request path. With 100-150 ms of L1 RPC latency the quote RPC took ~800 ms — right when a wallet is preparing a transaction. Prediction fee configuration (manaTarget, proving cost) was also memoized for process lifetime, so a running node never saw governance updates to it.

Approach

  • A background FeeSnapshotService (owned by the node factory, shared by the fee RPCs and the p2p mempool fee policy) refreshes whenever the archiver publishes a new L1 identity (new Archiver.getL1SyncSnapshot()), and publishes an immutable snapshot of complete precomputed quotes (current fee + all three ManaUsageEstimate prediction arrays) per candidate slot. Warm reads are pure memory lookups; a read that the snapshot cannot serve awaits a shared single-flight refresh and never substitutes a different slot's answer.
  • A refresh runs four batched eth_calls pinned to the archiver's block: chain tips + governance values, the checkpoints those tips name, per-candidate getManaMinFeeAt/canPruneAtTime, and the L1 fee oracle over the resulting prediction windows plus a trailing getTips re-read. Each stage's inputs are fully determined by the previous ones, and the trailing tips comparison fails the refresh if the stages saw divergent fallback-backend state — keeping last-good and backing off instead of publishing a quote assembled from two forks. Batches go through viem multicall (chunking disabled so each stage stays one atomic eth_call), with a typed parallel fallback where Multicall3 is not deployed.
  • Read-time selection preserves both legacy anchor rules (current fee floors on the raw pending checkpoint slot; predictions on the pinned-block slot): two map lookups against candidates materialized with two slots of headroom, plus a poll-tick lookahead so quotes survive empty-Ethereum-slot runs without a refresh. Solidity getManaMinFeeAt stays canonical for the current fee; predictions reuse the existing TS fee math. One fail-closed staleness bound (pinned L1-head age) catches a frozen provider or archiver; anything else stale is caught by the read-time identity check, which forces a refresh rather than serving a superseded snapshot.
  • The legacy computation is retained as a test-local (blockNumber, now) oracle; an Anvil equivalence suite compares snapshot vs legacy on identical inputs across state/clock cases, including prune-aware effective-parent selection and oracle rotations placed around the prediction window. The RPC bench gates warm calls on the service counter (zero read-triggered refreshes).

API changes

  • getCurrentMinFees/getPredictedMinFees can now throw typed errors (FeeQuoteUnavailableError, FeeQuoteStaleError) when the L1 view is stale or unavailable, instead of silently serving unboundedly stale data.
  • RollupContract.getManaMinFeeAt third parameter changed from stateOverride to an options object { blockNumber?, stateOverride? }; getTips gains an optional { blockNumber }.

Fixes A-1416

Review history

A second-model review of the first implementation surfaced three service-state defects (publish wedging after L1 height rollback, staleness checked before identity, backoff bypass on read-triggered refreshes), fixed in follow-up commits. The result was then judged over-engineered for a quote endpoint, and a reviewed simplification plan produced the final commit: the refresh restructured into the four exact pinned stages above (dissolving the provisional-window/coverage-validation/top-up machinery and the checkpoint-slot-invariant dependency), the clock-drift enumeration and two of three staleness bounds dropped, and the hand-rolled Multicall3 union API replaced by typed viem multicall wrappers. Net new code shrank ~40% while keeping both anchor rules, all 8 differential equivalence cases, and the bench gates.

Follow-ups (deliberately out of scope here)

  • Plumb the service tuning/staleness bounds through AztecNodeConfig/env vars (defaults are currently derived from L1 constants and overridable only in code).
  • Promote the FeeSnapshotStats counters and structured log fields to OpenTelemetry meters.

Serve getCurrentMinFees/getPredictedMinFees (and the p2p mempool fee policy) from an
in-memory FeeSnapshotService refreshed per L1 block, so warm calls issue zero L1 requests.

- ethereum: L1SyncSnapshot type; RollupContract getManaMinFeeAt options object, blockNumber
  option on getTips, non-memoized pinned governance reads, and a Multicall3-batched pinned
  fee reader (readFeeInputs) with a parallel-call fallback.
- archiver: atomic getL1SyncSnapshot() published with the existing L1 identity fields.
- sequencer-client: FeeSnapshotService with provisional window, two pinned multicall waves,
  wave-2 tips consistency check, exact-coverage top-up, complete precomputed per-candidate
  quotes, keyed single-flight with a first-snapshot promise, three-bound staleness policy,
  and slot-enumerating drift-window selection with symmetric miss handling and an RPC-side
  archiver identity check. FeePredictor math is extracted to fee_prediction.ts; the legacy
  computation is refactored into an explicit-input test-only equivalence oracle; the legacy
  per-request eth_blockNumber caches are removed.
- aztec-node: wire the service in the factory (started resources) and stop it from
  AztecNodeService before the archiver.
Add a small HTTP JSON-RPC reverse proxy fixture (configurable delay, per-method counters,
async-disposable teardown) plus a focused unit test. Extend node_rpc_perf to run the node's
L1 client through the proxy: warm getPredictedMinFees x20 sequential and x20 concurrent under
100ms injected latency, gated on zero read-triggered refreshes (fee-path L1 request counter)
and warm p95 within tolerance of a same-run local RPC baseline; the boundary refresh cost is
reported non-gating.
…ency stance

Describe how the fee RPCs are served from the background snapshot: the two anchor rules
(current vs prediction floors), the drift window with symmetric coverage misses, the three
staleness bounds, and the honest consistency stance including the impossible-mixed-state and
weaker parallel-call-fallback residuals.
Routing the node's L1 client through a front latency proxy started before setup conflicts with the
shared e2e setup's ownership of the Anvil clock and mining, which stalled block production and failed
the whole suite. Keep setup owning Anvil and gate the zero-fee-path-L1 property directly on the
FeeSnapshotService readTriggeredRefreshes counter (authoritative regardless of L1 latency), bounding
warm fee-path latency against a same-run local-RPC baseline. The latency proxy fixture and its unit
test are retained for standalone use.
The exact-coverage top-up only fetched the drift-window endpoint slots, while the read path enumerates
every slot between them. With a capped provisional window, intermediate wanted slots could stay
unmaterialized and reads would coverage-miss into an error no refresh repairs. Enumerate the same range
in both places.
@AztecBot AztecBot added the port-to-next Forward-port this merged PR into next label Jul 24, 2026
…s ordering, and backoff

- publish() no longer gates on block number: L1 identity is hash-authoritative and the height can move
  backwards (reorg or lagging fallback backend); the guard discarded every rebuild after a rollback and
  wedged reads. Serial refreshes plus the pre-publish identity re-check make it unnecessary.
- The read path checks archiver identity and coverage before the staleness bounds, and repairs a stale
  computation age with one refresh instead of failing closed; head-age and future-head still fail
  immediately since the archiver has nothing newer.
- Read-triggered refreshes respect the failure backoff so RPC traffic cannot hammer a failing L1, and
  exceeding the refresh restart bound now counts as a failure and engages backoff.
- Snapshots publish their actual materialized slot bounds rather than the provisional window, so a capped
  window plus top-up no longer makes the poll loop refresh continuously.
- Fail-closed reads (computation-stale, head-stale, future-head, coverage timeout) now have counters.
@spalladino
spalladino marked this pull request as draft July 24, 2026 18:53
…ment

Extend the fee equivalence and invariant suites to the parts of plan v4's matrix that were missing:

- fee_snapshot_equivalence: add cases with a written pending checkpoint beyond the proven tip
  (pending != proven), exercising both effective-parent branches — canPrune=false selects the pending
  checkpoint, canPrune=true selects the proven checkpoint — each verified via canPruneAtTime and
  getEffectivePendingCheckpoint before comparing snapshot vs legacy on identical (blockNumber, now).
- fee_snapshot_equivalence: replace the single oracle-update case with an explicit rotation placed below,
  inside, and above the prediction window; the placement is confirmed via getL1FeesAt on the window slots
  before each comparison.
- rollup_fee_reads: add a checkpoint-slot invariant case with a pending checkpoint written at a non-zero
  slot, asserting pendingCheckpointSlot <= pinnedSlot across advances.

State is created with anvil setStorageAt against the Rollup tips + temp-checkpoint-log slots and restored to
genesis after each case so it does not leak into other tests.
Cuts the defensive superstructure the background fee snapshot grew, keeping the goal (warm
getPredictedMinFees/getCurrentMinFees issue zero L1 requests) and both legacy anchor rules.

Refresh is now four exact pinned stages (tips + governance, checkpoints, per-candidate minFee +
canPrune, derived L1 fees plus a trailing tips re-read), which removes the window circularity that
motivated the provisional window, the coverage-validation pass, the top-up waves, and the
checkpoint-slot invariant. The trailing tips comparison stays as the one cheap defense against
persistent fallback-backend divergence: on mismatch the refresh fails, the last-good snapshot stays
stored, and the backoff schedules the retry.

Read-time selection collapses to the two anchor slots (two map lookups, no drift-window enumeration
or element-wise merging), staleness shrinks to the pinned L1-head age bound, and the errors, stats
and config shrink to 3/3/7. Single-flight is unkeyed, cold start uses the same refresh-await path,
and executeTimeout replaces the hand-rolled race.

On the ethereum side the RollupFeeRead/RollupFeeReadResult union, its hand-rolled aggregate3
encode/decode and the FeeReadResults accessor are replaced by four directly-typed per-stage
multicall wrappers on RollupContract, each with a typed Promise.all fallback over the existing
pinned getters.

fee_snapshot.ts is absorbed into the service, legacy_fee_oracle.ts moves into the equivalence suite
as test-local helpers, and the unused bench latency proxy is dropped. All 8 differential
equivalence cases against the legacy oracle survive unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

port-to-next Forward-port this merged PR into next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants