feat(node): serve fee RPCs from a background L1 fee snapshot - #24970
Draft
spalladino wants to merge 8 commits into
Draft
feat(node): serve fee RPCs from a background L1 fee snapshot#24970spalladino wants to merge 8 commits into
spalladino wants to merge 8 commits into
Conversation
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.
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes warm
getPredictedMinFees/getCurrentMinFeescalls zero-L1-request in-memory reads, so quote latency no longer scales with L1 RPC latency.Context
Every
getPredictedMinFeescall paid two uncachedeth_blockNumberround trips, and each new L1 block added up to ~8 sequentialeth_callwaves 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
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 (newArchiver.getL1SyncSnapshot()), and publishes an immutable snapshot of complete precomputed quotes (current fee + all threeManaUsageEstimateprediction 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.eth_calls pinned to the archiver's block: chain tips + governance values, the checkpoints those tips name, per-candidategetManaMinFeeAt/canPruneAtTime, and the L1 fee oracle over the resulting prediction windows plus a trailinggetTipsre-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 viemmulticall(chunking disabled so each stage stays one atomiceth_call), with a typed parallel fallback where Multicall3 is not deployed.getManaMinFeeAtstays 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.(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/getPredictedMinFeescan now throw typed errors (FeeQuoteUnavailableError,FeeQuoteStaleError) when the L1 view is stale or unavailable, instead of silently serving unboundedly stale data.RollupContract.getManaMinFeeAtthird parameter changed fromstateOverrideto an options object{ blockNumber?, stateOverride? };getTipsgains 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
multicallwrappers. 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)
AztecNodeConfig/env vars (defaults are currently derived from L1 constants and overridable only in code).FeeSnapshotStatscounters and structured log fields to OpenTelemetry meters.