Skip to content

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

Draft
spalladino wants to merge 7 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 7 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 once per L1 block, pinned to the archiver's atomically published 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 refresh batches its reads into two pinned Multicall3 eth_calls — tips + governance values + per-slot getManaMinFeeAt/canPruneAtTime/getL1FeesAt, then both checkpoint fee headers plus a getTips re-read that discards the refresh on cross-wave mismatch — followed by an exact-coverage top-up and an archiver identity re-check before the atomic publish. Falls back to parallel pinned individual calls 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), evaluated across a small clock-drift window with element-wise max merging. Solidity getManaMinFeeAt stays canonical for the current fee; predictions reuse the existing TS fee math. Coverage misses trigger a shared single-flight refresh and never substitute a different slot's answer.
  • Three independent fail-closed staleness bounds (refresh age, L1-head age, future-dated head), keep-last-good on refresh failure with backoff, and governance values read per refresh.
  • The legacy computation is retained as an explicit-input (blockNumber, now) test oracle; an Anvil equivalence suite compares snapshot vs legacy on identical inputs across state/clock cases, and locks the within-slot fee constancy and checkpoint-slot invariant the design relies on. The RPC bench gates warm calls on the service counter (zero read-triggered refreshes) and includes a JSON-RPC latency-proxy fixture.

API changes

  • getCurrentMinFees/getPredictedMinFees can now throw typed FeeSnapshot*Errors 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 fixes

A second-model review of the implementation surfaced three service-state defects, fixed in follow-up commits: publish() gated on block number and would wedge after an L1 height rollback (reorg or lagging fallback backend); staleness bounds were checked before the archiver identity, failing reads that one refresh would repair; and read-triggered refreshes bypassed the failure backoff, turning RPC traffic into L1 load during outages. Snapshots also now publish their actual materialized slot bounds, and fail-closed reads have counters.

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.
  • Wire the latency proxy fixture into the bench for an end-to-end injected-latency scenario (the zero-fee-path-L1 property is currently gated on the service counter, which is authoritative but narrower).

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.
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